diff --git a/docs/content/in-depth/client/_index.md b/docs/content/in-depth/client/_index.md index 87628db1..4c4b4300 100644 --- a/docs/content/in-depth/client/_index.md +++ b/docs/content/in-depth/client/_index.md @@ -101,6 +101,10 @@ There are four ways to configure a HttpClient for communication with the datasyn } ``` +> [!TIP] +> You can easily set up basic and bearer authentication when using `HttpClientOptions` using the `GenericAuthenticationProvider`. +> See the [authentication guide](./auth.md) for more details. + You must configure one of these options so that the data synchronization services know which datasync service to communicate with. ### Configuring entities to synchronize diff --git a/docs/content/in-depth/client/auth.md b/docs/content/in-depth/client/auth.md new file mode 100644 index 00000000..97c5dbec --- /dev/null +++ b/docs/content/in-depth/client/auth.md @@ -0,0 +1,98 @@ ++++ +title = "Authentication" +weight = 30 ++++ + +Most of the time, you will want to use bearer authentication so that you can use a JWT (Json Web Token) obtained from an OIDC server. This is so prevalent that we provide an easy mechanism to add this to your application via a `GenericAuthenticationProvider`. The authentication provider only requests tokens from your token retrieval method when required (when the provided token is close to expiring or has expired). + +The `GenericAuthenticationProvider` and associated classes are in the `CommunityToolkit.Datasync.Client.Authentication` namespace. + +## Set up authentication and authorization on the datasync service + +You must set up authentication and authorization on the datasync service first. The authentication and authorization is regular ASP.NET Core +identity, so [follow the instructions](https://learn.microsoft.com/aspnet/core/security/) for your particular provider. + +## Create a method to retrieve the token + +You need to implement a method to retrieve the token. Normally, this uses the library that is provided for the purpose. For example: + +* Microsoft logins use [Microsoft.Identity.Client](https://www.nuget.org/packages/Microsoft.Identity.Client). +* Other logins on MAUI may use [WebAuthenticator](https://learn.microsoft.com/dotnet/maui/platform-integration/communication/authentication) + +Whatever mechanism you use, this must be set up first. If your application is unable to get a token, the authentication middleware cannot pass it onto the server. + +## Add the GenericAuthenticationProvider to your client + +The `GenericAuthenticationProvider` takes a function that retrieves the token. For example: + +```csharp +public async Task GetTokenAsync(CancellationToken cancellationToken = default) +{ + // Put the logic to retrieve the JWT here. + + DateTimeOffset expiresOn = expiry-date; + return new AuthenticationToken() + { + Token = "the JWT you need to pass to the service", + UserId = "the user ID", + DisplayName = "the display Name", + ExpiresOn = expiresOn + }; +} +``` + +You can now create a GenericAuthenticationProvider: + +```csharp +GenericAuthenticationProvider authProvider = new(GetTokenAsync); +``` + +### Build HttpClientOptions with the authentication provider + +The authentication provider is a `DelegatingHandler`, so it belongs in the `HttpPipeline`: + +```csharp +HttpClientOptions options = new() +{ + HttpPipeline = [ authProvider ], + Endpont = "https://myservice.azurewebsites.net" +}; +``` + +You can then use this options structure when constructing a client (either in the `OnDatasyncInitialization()` method or when constructing the `DatasyncServiceClient`). + +> [!TIP] +> It's normal to inject the authentication provider as a singleton in an MVVM scenario with dependency injection. + +## Forcing a login request + +Sometimes, you want to force a login request; for example, in response to a button click. You can call `LoginAsync()` on the authentication provider to trigger a login sequence. The token will then be used until it expires. + +## Refresh token + +Most providers allow you to request a "refresh token" that can be used to silently request an access token for use in accessing the datasync service. You can store and retrieve refresh tokens from local storage in your token retrieval method. The `GenericAuthenticationProvider` does not natively handle refresh tokens for you. + +## Other options + +You can specify which header is used for authorization. For example, Azure App Service Authentication and Authorization service uses the `X-ZUMO-AUTH` header to transmit the token. This is easily set up: + +```csharp +GenericAuthenticationProvider authProvider = new(GetTokenAsync, "X-ZUMO-AUTH"); +``` + +Similarly, you can specify the authentication type for the authorization header (instead of Bearer): + +```csharp +GenericAuthenticationProvider authProvider = new(GetTokenAsync, "Authorization", "Basic"); +``` + +This gives you significant flexibility to build the authentication mechanism appropriate for your application. + +By default, a new token is requested if the old token is expired or within 2 minutes of expiry. You can adjust the amount of buffer time using the `RefreshBufferTimeSpan` property: + +```csharp +GenericAuthenticationProvider authProvider = new(GetTokenAsync) +{ + RefreshBufferTimeSpan = TimeSpan.FromSeconds(30) +}; +``` diff --git a/docs/content/in-depth/client/oneline-operations.md b/docs/content/in-depth/client/oneline-operations.md index 88e0ab97..9ffbb5bb 100644 --- a/docs/content/in-depth/client/oneline-operations.md +++ b/docs/content/in-depth/client/oneline-operations.md @@ -55,6 +55,9 @@ public IHttpClientFactory GetClientFactory() The first element in the list becomes the root handler, then each successive handler is chained to the `InnerHandler` of the previous handler. +> [!TIP] +> You can easily set up basic and bearer authentication using the `GenericAuthenticationProvider`. See the [authentication guide](./auth.md) for more details. + ## Create a Datasync Service Client Now that you have something to generate `HttpClient` objects, you can use it to create a `DatasyncServiceClient` for a specific service: diff --git a/samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs b/samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs index 496fed08..0701f1f4 100644 --- a/samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs +++ b/samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Diagnostics; using TodoApp.MAUI.Models; using TodoApp.MAUI.ViewModels; @@ -22,14 +21,14 @@ public MainPage() protected override void OnAppearing() { base.OnAppearing(); - this._viewModel.OnActivated(); + this._viewModel.RefreshItemsCommand.Execute(); } public void OnListItemTapped(object sender, ItemTappedEventArgs e) { if (e.Item is TodoItem item) { - this._viewModel.SelectItemCommand.Execute(item); + this._viewModel.UpdateItemCommand.Execute(item); } if (sender is ListView itemList) diff --git a/samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj b/samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj index bde2871c..95bce9db 100644 --- a/samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj +++ b/samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj @@ -63,6 +63,7 @@ + diff --git a/samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs b/samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs index 2f3ea0da..d3e2d3a0 100644 --- a/samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs +++ b/samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs @@ -3,41 +3,22 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Datasync.Client; +using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.EntityFrameworkCore; -using System.ComponentModel; -using System.Windows.Input; using TodoApp.MAUI.Models; using TodoApp.MAUI.Services; namespace TodoApp.MAUI.ViewModels; -public class MainViewModel(AppDbContext context, IAlertService alertService) : INotifyPropertyChanged +public class MainViewModel(AppDbContext context, IAlertService alertService) : ObservableRecipient { + [ObservableProperty] private bool _isRefreshing = false; - public ICommand AddItemCommand - => new Command(async (Entry entry) => await AddItemAsync(entry.Text)); + [ObservableProperty] + private ConcurrentObservableCollection items = []; - public ICommand RefreshItemsCommand - => new Command(async () => await RefreshItemsAsync()); - - public ICommand SelectItemCommand - => new Command(async (TodoItem item) => await UpdateItemAsync(item.Id, !item.IsComplete)); - - public ConcurrentObservableCollection Items { get; } = new(); - - public bool IsRefreshing - { - get => this._isRefreshing; - set => SetProperty(ref this._isRefreshing, value, nameof(IsRefreshing)); - } - - public async void OnActivated() - { - await RefreshItemsAsync(); - } - - public async Task RefreshItemsAsync() + public async Task RefreshItemsAsync(CancellationToken cancellationToken = default) { if (IsRefreshing) { @@ -46,8 +27,8 @@ public async Task RefreshItemsAsync() try { - await context.SynchronizeAsync(); - List items = await context.TodoItems.ToListAsync(); + await context.SynchronizeAsync(cancellationToken); + List items = await context.TodoItems.ToListAsync(cancellationToken); Items.ReplaceAll(items); } catch (Exception ex) @@ -60,17 +41,17 @@ public async Task RefreshItemsAsync() } } - public async Task UpdateItemAsync(string itemId, bool isComplete) + public async Task UpdateItemAsync(string itemId, CancellationToken cancellationToken = default) { try { TodoItem? item = await context.TodoItems.FindAsync([itemId]); if (item is not null) { - item.IsComplete = isComplete; + item.IsComplete = !item.IsComplete; _ = context.TodoItems.Update(item); _ = Items.ReplaceIf(x => x.Id == itemId, item); - _ = await context.SaveChangesAsync(); + _ = await context.SaveChangesAsync(cancellationToken); } } catch (Exception ex) @@ -79,13 +60,13 @@ public async Task UpdateItemAsync(string itemId, bool isComplete) } } - public async Task AddItemAsync(string text) + public async Task AddItemAsync(string text, CancellationToken cancellationToken = default) { try { TodoItem item = new() { Title = text }; _ = context.TodoItems.Add(item); - _ = await context.SaveChangesAsync(); + _ = await context.SaveChangesAsync(cancellationToken); Items.Add(item); } catch (Exception ex) @@ -93,40 +74,4 @@ public async Task AddItemAsync(string text) await alertService.ShowErrorAlertAsync("AddItem", ex.Message); } } - - #region INotifyPropertyChanged - /// - /// The event handler required by - /// - public event PropertyChangedEventHandler? PropertyChanged; - - /// - /// Sets a backing store value and notify watchers of the change. The type must - /// implement for proper comparisons. - /// - /// The type of the value - /// The backing store - /// The new value - /// - protected void SetProperty(ref T storage, T value, string? propertyName = null) where T : notnull - { - if (!storage.Equals(value)) - { - storage = value; - NotifyPropertyChanged(propertyName); - } - } - - /// - /// Notifies the data context that the property named has changed value. - /// - /// The name of the property - protected void NotifyPropertyChanged(string? propertyName = null) - { - if (propertyName != null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } - #endregion } diff --git a/src/CommunityToolkit.Datasync.Client/Authentication/AuthenticationProvider.cs b/src/CommunityToolkit.Datasync.Client/Authentication/AuthenticationProvider.cs new file mode 100644 index 00000000..fb35aeef --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Authentication/AuthenticationProvider.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Datasync.Client.Authentication; + +/// +/// Definition of an authentication provider, which is a specific type of delegating +/// handler that handles authentication updates. +/// +public abstract class AuthenticationProvider : DelegatingHandler +{ + /// + /// The display name for the currently logged in user. This may be null. + /// + public string? DisplayName { get; protected set; } + + /// + /// If true, the user is logged in (and the UserId is available). + /// + public bool IsLoggedIn { get; protected set; } + + /// + /// The User ID for this user. + /// + public string? UserId { get; protected set; } + + /// + /// Initiate a login request out of band of the pipeline. This can be used to + /// initiate the login process via a button. + /// + /// A to observe. + /// An async task that resolves when the login is complete. + public abstract Task LoginAsync(CancellationToken cancellationToken = default); +} diff --git a/src/CommunityToolkit.Datasync.Client/Authentication/AuthenticationToken.cs b/src/CommunityToolkit.Datasync.Client/Authentication/AuthenticationToken.cs new file mode 100644 index 00000000..64e191dc --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Authentication/AuthenticationToken.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Datasync.Client.Authentication; + +/// +/// Definition of an authentication token response. +/// +public struct AuthenticationToken +{ + /// + /// The display name for this user. + /// + public string DisplayName { get; set; } + + /// + /// The expiry date of the JWT Token + /// + public DateTimeOffset ExpiresOn { get; set; } + /// + /// The actual JWT Token + /// + public string Token { get; set; } + + /// + /// The User Id for this user + /// + public string UserId { get; set; } + + /// + /// Return a visual representation of the authentication token for logging purposes. + /// + /// The string representation of the authentication token + public override readonly string ToString() + => $"AuthenticationToken(DisplayName=\"{DisplayName}\",ExpiresOn=\"{ExpiresOn}\",Token=\"{Token}\",UserId=\"{UserId}\")"; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Client/Authentication/GenericAuthenticationProvider.cs b/src/CommunityToolkit.Datasync.Client/Authentication/GenericAuthenticationProvider.cs new file mode 100644 index 00000000..5428499b --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Authentication/GenericAuthenticationProvider.cs @@ -0,0 +1,153 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Datasync.Client.Authentication; + +/// +/// A generic authentication provider that gets a JWT token from the specified +/// action and puts it in the Authorization header. The JWT is kept around until +/// about 2-3 minutes before it expires, at which point a new one is requested. +/// +public class GenericAuthenticationProvider : AuthenticationProvider +{ + private TimeSpan _bufferPeriod = new(0, 2, 0); // Two minutes; + + /// + /// Creates a new by specifying a function for the token requester. + /// + /// The token requester + /// The name of the header + /// The authentication type (if specified) + public GenericAuthenticationProvider(Func> asyncTokenRequestor, string headerName = "Authorization", string? authenticationType = null) + { + ArgumentNullException.ThrowIfNull(asyncTokenRequestor); + ArgumentNullException.ThrowIfNullOrWhiteSpace(headerName); + + if (headerName.Equals("authorization", StringComparison.InvariantCultureIgnoreCase)) + { + authenticationType ??= "Bearer"; + } + + if (authenticationType != null && string.IsNullOrWhiteSpace(authenticationType)) + { + throw new ArgumentException($"{nameof(authenticationType)} must be specified (or null if not Authorization header)", nameof(authenticationType)); + } + + TokenRequestorAsync = asyncTokenRequestor; + HeaderName = headerName; + AuthenticationType = authenticationType; + } + + /// + /// The function used to request the token. + /// + internal Func> TokenRequestorAsync { get; set; } + + /// + /// The header name to use for authentication + /// + internal string HeaderName { get; } + + /// + /// The authentication type (normally Bearer) + /// + internal string? AuthenticationType { get; } + + /// + /// The current authentication token + /// + internal AuthenticationToken? Current { get; set; } + + /// + /// The amount of time prior to expiry that we refresh the token + /// + public TimeSpan RefreshBufferTimeSpan + { + get => this._bufferPeriod; + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.FromSeconds(1), nameof(RefreshBufferTimeSpan)); + this._bufferPeriod = value; + } + } + + /// + /// Initiate a login request out of band of the pipeline. This can be used to + /// initiate the login process via a button. + /// + /// A to observe. + /// An async task that resolves when the login is complete. + public override Task LoginAsync(CancellationToken cancellationToken = default) + => GetTokenAsync(true, cancellationToken); + + /// + /// Gets a valid authentication token + /// + /// If true, forces re-acquisition of the authentication token + /// A to observe. + /// The authentication token (asynchronously) + public async Task GetTokenAsync(bool force = false, CancellationToken cancellationToken = default) + { + if (force || IsExpired(Current)) + { + Current = await TokenRequestorAsync.Invoke(cancellationToken).ConfigureAwait(false); + IsLoggedIn = !IsExpired(Current); + UserId = !IsLoggedIn ? null : Current.Value.UserId; + DisplayName = !IsLoggedIn ? null : Current.Value.DisplayName; + } + + if (IsExpired(Current)) + { + System.Diagnostics.Debug.WriteLine($"GenericAuthenticationProvider:: Current is expired - token = {Current}"); + } + + return IsExpired(Current) ? null : Current?.Token; + } + + /// + /// Determines if the token is valid and unexpired. + /// + /// The token + /// true if the token is valid. + internal bool IsExpired(AuthenticationToken? token) + { + try + { + if (!token.HasValue) + { + return true; + } + + return DateTimeOffset.Now >= token.Value.ExpiresOn.Subtract(RefreshBufferTimeSpan); + } + catch + { + // If any errors occurred, treat as if the token is expired. + return true; + } + } + + /// + /// The delegating handler for this request - injects the authorization header into the request. + /// + /// The request + /// A + /// The response (asynchronously) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) + { + if (request.Headers.Contains(HeaderName)) + { + _ = request.Headers.Remove(HeaderName); + } + + string? token = await GetTokenAsync(false, cancellationToken).ConfigureAwait(false); + if (token is not null) + { + string headerValue = AuthenticationType != null ? $"{AuthenticationType} {token}" : token; + request.Headers.Add(HeaderName, headerValue); + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Authentication/GenericAuthenticationProvider_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Authentication/GenericAuthenticationProvider_Tests.cs new file mode 100644 index 00000000..f8dc7239 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Authentication/GenericAuthenticationProvider_Tests.cs @@ -0,0 +1,358 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Client.Authentication; +using CommunityToolkit.Datasync.TestCommon; +using CommunityToolkit.Datasync.TestCommon.Mocks; +using Microsoft.Net.Http.Headers; +using System.Net; + +namespace CommunityToolkit.Datasync.Client.Test.Authentication; + +[ExcludeFromCodeCoverage] +public class GenericAuthenticationProvider_Tests +{ + #region Helpers + /// + /// An authentication token that is expired. + /// + private AuthenticationToken ExpiredAuthenticationToken { get; } = new() + { + DisplayName = "John Smith", + ExpiresOn = DateTimeOffset.Now.AddMinutes(-5), + Token = "YmFzaWMgdG9rZW4gZm9yIHRlc3Rpbmc=", + UserId = "the_doctor" + }; + + /// + /// A completely valid authentication token. + /// + private AuthenticationToken ValidAuthenticationToken { get; } = new() + { + DisplayName = "John Smith", + ExpiresOn = DateTimeOffset.Now.AddMinutes(5), + Token = "YmFzaWMgdG9rZW4gZm9yIHRlc3Rpbmc=", + UserId = "the_doctor" + }; + #endregion + + [Fact] + public void Ctor_NullTokenRequestor_Throws() + { + Action act = () => _ = new GenericAuthenticationProvider(null); + act.Should().Throw(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData(" \t ")] + public void Ctor_WhitespaceHeader_Throws(string headerName) + { + Action act = () => _ = new GenericAuthenticationProvider(_ => Task.FromResult(ValidAuthenticationToken), headerName); + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData(" \t ")] + [Trait("Method", "Ctor")] + public void Ctor_Authorization_RequiresType(string authType) + { + Action act = () => _ = new GenericAuthenticationProvider(_ => Task.FromResult(ValidAuthenticationToken), "Authorization", authType); + act.Should().Throw(); + } + + [Fact] + public void Ctor_CanSetTokenRequestor() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)); + sut.HeaderName.Should().Be("Authorization"); + sut.AuthenticationType.Should().Be("Bearer"); + sut.Current.Should().BeNull(); + sut.RefreshBufferTimeSpan.Should().BeGreaterThan(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Ctor_CanDoAuthBasic() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken), "Authorization", "Basic"); + sut.HeaderName.Should().Be("Authorization"); + sut.AuthenticationType.Should().Be("Basic"); + sut.Current.Should().BeNull(); + sut.RefreshBufferTimeSpan.Should().BeGreaterThan(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Ctor_CanDoAuthBearer() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken), "Authorization"); + sut.HeaderName.Should().Be("Authorization"); + sut.AuthenticationType.Should().Be("Bearer"); + sut.Current.Should().BeNull(); + sut.RefreshBufferTimeSpan.Should().BeGreaterThan(TimeSpan.FromSeconds(1)); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + public void RefreshBufferTimeSpan_CannotBeSmall(long ms) + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)); + Action act = () => sut.RefreshBufferTimeSpan = TimeSpan.FromMilliseconds(ms); + act.Should().Throw(); + } + + [Fact] + public void RefreshBufferTimeSpan_Roundtrips() + { + TimeSpan ts = TimeSpan.FromMinutes(1); + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) { RefreshBufferTimeSpan = ts }; + sut.RefreshBufferTimeSpan.Should().Be(ts); + } + + [Fact] + public void IsExpired_NullToken_ReturnsTrue() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) + { + RefreshBufferTimeSpan = TimeSpan.FromMinutes(2) + }; + sut.IsExpired(sut.Current).Should().BeTrue(); + } + + [Fact] + public void IsExpired_NotExpired_ReturnsFalse() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) + { + RefreshBufferTimeSpan = TimeSpan.FromMinutes(2), + Current = new AuthenticationToken { ExpiresOn = DateTimeOffset.Now.AddMinutes(4) } + }; + sut.IsExpired(sut.Current).Should().BeFalse(); + } + + [Fact] + public void IsExpired_InBuffer_ReturnsTrue() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) + { + RefreshBufferTimeSpan = TimeSpan.FromMinutes(2), + Current = new AuthenticationToken { ExpiresOn = DateTimeOffset.Now.AddMinutes(-1) } + }; + sut.IsExpired(sut.Current).Should().BeTrue(); + } + + [Fact] + public void IsExpired_Expired_ReturnsTrue() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) + { + RefreshBufferTimeSpan = TimeSpan.FromMinutes(2), + Current = new AuthenticationToken { ExpiresOn = DateTimeOffset.Now.AddMinutes(-3) } + }; + sut.IsExpired(sut.Current).Should().BeTrue(); + } + + [Fact] + public void IsExpired_ExpiredToken_ReturnsTrue() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) + { + RefreshBufferTimeSpan = TimeSpan.FromMinutes(2) + }; + sut.IsExpired(ExpiredAuthenticationToken).Should().BeTrue(); + } + + [Fact] + public void IsExpired_BasicToken_ReturnsFalse() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) + { + RefreshBufferTimeSpan = TimeSpan.FromMinutes(2) + }; + sut.IsExpired(ValidAuthenticationToken).Should().BeFalse(); + } + + [Fact] + public void IsExpired_NoExpiration_ReturnsTrue() + { + AuthenticationToken authtoken = new() + { + DisplayName = ValidAuthenticationToken.DisplayName, + Token = ValidAuthenticationToken.Token, + UserId = ValidAuthenticationToken.UserId + }; + GenericAuthenticationProvider sut = new(_ => Task.FromResult(authtoken)) + { + RefreshBufferTimeSpan = TimeSpan.FromMinutes(2) + }; + sut.IsExpired(authtoken).Should().BeTrue(); + } + + [Fact] + public async Task GetTokenAsync_CallsOnFirstRun() + { + int count = 0; + GenericAuthenticationProvider sut = new(_ => { count++; return Task.FromResult(ValidAuthenticationToken); }); + string actual = await sut.GetTokenAsync(); + actual.Should().Be(ValidAuthenticationToken.Token); + count.Should().Be(1); + } + + [Fact] + public async Task GetTokenAsync_CachesResult() + { + int count = 0; + GenericAuthenticationProvider sut = new(_ => { count++; return Task.FromResult(ValidAuthenticationToken); }); + string firstCall = await sut.GetTokenAsync(); + string secondCall = await sut.GetTokenAsync(); + firstCall.Should().Be(ValidAuthenticationToken.Token); + secondCall.Should().Be(ValidAuthenticationToken.Token); + count.Should().Be(1); + } + + [Fact] + public async Task GetTokenAsync_CallsOnForce() + { + int count = 0; + GenericAuthenticationProvider sut = new(_ => { count++; return Task.FromResult(ValidAuthenticationToken); }); + string firstCall = await sut.GetTokenAsync(); + firstCall.Should().Be(ValidAuthenticationToken.Token); + string secondCall = await sut.GetTokenAsync(true); + secondCall.Should().Be(ValidAuthenticationToken.Token); + count.Should().Be(2); + } + + [Fact] + public async Task GetTokenAsync_LogsOutWhenExpired() + { + GenericAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)); + string firstCall = await sut.GetTokenAsync(); + firstCall.Should().Be(ValidAuthenticationToken.Token); + sut.DisplayName.Should().Be(ValidAuthenticationToken.DisplayName); + sut.UserId.Should().Be(ValidAuthenticationToken.UserId); + sut.IsLoggedIn.Should().BeTrue(); + + sut.TokenRequestorAsync = _ => Task.FromResult(ExpiredAuthenticationToken); + string secondCall = await sut.GetTokenAsync(true); + secondCall.Should().BeNull(); + sut.DisplayName.Should().BeNull(); + sut.UserId.Should().BeNull(); + sut.IsLoggedIn.Should().BeFalse(); + } + + [Fact] + public async Task LoginAsync_CallsTokenRequestor() + { + int count = 0; + GenericAuthenticationProvider sut = new(_ => { count++; return Task.FromResult(ValidAuthenticationToken); }); + await sut.LoginAsync(); + count.Should().Be(1); + } + + [Fact] + public async Task LoginAsync_ForcesTokenRequestor() + { + int count = 0; + GenericAuthenticationProvider sut = new(_ => { count++; return Task.FromResult(ValidAuthenticationToken); }); + string firstCall = await sut.GetTokenAsync(); + firstCall.Should().Be(ValidAuthenticationToken.Token); + await sut.LoginAsync(); + count.Should().Be(2); + } + + [Fact] + public async Task SendAsync_AddsHeader_BearerAuth() + { + MockDelegatingHandler handler = new(); + handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.OK)); + HttpRequestMessage request = new(HttpMethod.Get, "http://localhost/test"); + WrappedAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) + { + InnerHandler = handler + }; + + HttpResponseMessage response = await sut.WrappedSendAsync(request); + + response.Should().NotBeNull(); + + handler.Requests.Should().ContainSingle(); + handler.Requests[0].Should().HaveHeader("Authorization", $"Bearer {ValidAuthenticationToken.Token}"); + } + + [Fact] + public async Task SendAsync_NoHeader_WhenExpired() + { + MockDelegatingHandler handler = new(); + handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.OK)); + HttpRequestMessage request = new(HttpMethod.Get, "http://localhost/test"); + WrappedAuthenticationProvider sut = new(_ => Task.FromResult(ExpiredAuthenticationToken)) + { + InnerHandler = handler + }; + + HttpResponseMessage response = await sut.WrappedSendAsync(request); + + response.Should().NotBeNull(); + + handler.Requests.Should().ContainSingle(); + handler.Requests[0].Should().NotHaveHeader("Authorization"); + } + + [Fact] + public async Task SendAsync_RemoveHeader_WhenExpired() + { + MockDelegatingHandler handler = new(); + handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.OK)); + HttpRequestMessage request = new(HttpMethod.Get, "http://localhost/test"); + request.Headers.Add("Authorization", "Bearer 1234"); + WrappedAuthenticationProvider sut = new(_ => Task.FromResult(ExpiredAuthenticationToken)) + { + InnerHandler = handler + }; + + HttpResponseMessage response = await sut.WrappedSendAsync(request); + + response.Should().NotBeNull(); + + handler.Requests.Should().ContainSingle(); + handler.Requests[0].Should().NotHaveHeader("Authorization"); + } + + [Fact] + public async Task SendAsync_OverwritesHeader_WhenNotExpired() + { + MockDelegatingHandler handler = new(); + handler.Responses.Add(new HttpResponseMessage(HttpStatusCode.OK)); + HttpRequestMessage request = new(HttpMethod.Get, "http://localhost/test"); + request.Headers.Add("Authorization", "Bearer 1234"); + WrappedAuthenticationProvider sut = new(_ => Task.FromResult(ValidAuthenticationToken)) + { + InnerHandler = handler + }; + + HttpResponseMessage response = await sut.WrappedSendAsync(request); + + response.Should().NotBeNull(); + + handler.Requests.Should().ContainSingle(); + handler.Requests[0].Should().HaveHeader("Authorization", $"Bearer {ValidAuthenticationToken.Token}"); + } + + /// + /// Wrap of the that provides public access to `SendAsync()` + /// > requestor, string header = "Authorization", string authType = null) + : GenericAuthenticationProvider(requestor, header, authType) + { + public Task WrappedSendAsync(HttpRequestMessage request, CancellationToken token = default) + => base.SendAsync(request, token); + } +}