Skip to content

Commit

Permalink
(#96) authentication. (#97)
Browse files Browse the repository at this point in the history
  • Loading branch information
adrianhall authored Sep 2, 2024
1 parent d502b80 commit 7640cbc
Show file tree
Hide file tree
Showing 10 changed files with 704 additions and 71 deletions.
4 changes: 4 additions & 0 deletions docs/content/in-depth/client/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions docs/content/in-depth/client/auth.md
Original file line number Diff line number Diff line change
@@ -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<AuthenticationToken> 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)
};
```
3 changes: 3 additions & 0 deletions docs/content/in-depth/client/oneline-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.0" />
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
Expand Down
81 changes: 13 additions & 68 deletions samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entry>(async (Entry entry) => await AddItemAsync(entry.Text));
[ObservableProperty]
private ConcurrentObservableCollection<TodoItem> items = [];

public ICommand RefreshItemsCommand
=> new Command(async () => await RefreshItemsAsync());

public ICommand SelectItemCommand
=> new Command<TodoItem>(async (TodoItem item) => await UpdateItemAsync(item.Id, !item.IsComplete));

public ConcurrentObservableCollection<TodoItem> 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)
{
Expand All @@ -46,8 +27,8 @@ public async Task RefreshItemsAsync()

try
{
await context.SynchronizeAsync();
List<TodoItem> items = await context.TodoItems.ToListAsync();
await context.SynchronizeAsync(cancellationToken);
List<TodoItem> items = await context.TodoItems.ToListAsync(cancellationToken);
Items.ReplaceAll(items);
}
catch (Exception ex)
Expand All @@ -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)
Expand All @@ -79,54 +60,18 @@ 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)
{
await alertService.ShowErrorAlertAsync("AddItem", ex.Message);
}
}

#region INotifyPropertyChanged
/// <summary>
/// The event handler required by <see cref="INotifyPropertyChanged"/>
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;

/// <summary>
/// Sets a backing store value and notify watchers of the change. The type must
/// implement <see cref="IEquatable{T}"/> for proper comparisons.
/// </summary>
/// <typeparam name="T">The type of the value</typeparam>
/// <param name="storage">The backing store</param>
/// <param name="value">The new value</param>
/// <param name="propertyName"></param>
protected void SetProperty<T>(ref T storage, T value, string? propertyName = null) where T : notnull
{
if (!storage.Equals(value))
{
storage = value;
NotifyPropertyChanged(propertyName);
}
}

/// <summary>
/// Notifies the data context that the property named has changed value.
/// </summary>
/// <param name="propertyName">The name of the property</param>
protected void NotifyPropertyChanged(string? propertyName = null)
{
if (propertyName != null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
#endregion
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Definition of an authentication provider, which is a specific type of delegating
/// handler that handles authentication updates.
/// </summary>
public abstract class AuthenticationProvider : DelegatingHandler
{
/// <summary>
/// The display name for the currently logged in user. This may be null.
/// </summary>
public string? DisplayName { get; protected set; }

/// <summary>
/// If true, the user is logged in (and the UserId is available).
/// </summary>
public bool IsLoggedIn { get; protected set; }

/// <summary>
/// The User ID for this user.
/// </summary>
public string? UserId { get; protected set; }

/// <summary>
/// Initiate a login request out of band of the pipeline. This can be used to
/// initiate the login process via a button.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
/// <returns>An async task that resolves when the login is complete.</returns>
public abstract Task LoginAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Definition of an authentication token response.
/// </summary>
public struct AuthenticationToken
{
/// <summary>
/// The display name for this user.
/// </summary>
public string DisplayName { get; set; }

/// <summary>
/// The expiry date of the JWT Token
/// </summary>
public DateTimeOffset ExpiresOn { get; set; }
/// <summary>
/// The actual JWT Token
/// </summary>
public string Token { get; set; }

/// <summary>
/// The User Id for this user
/// </summary>
public string UserId { get; set; }

/// <summary>
/// Return a visual representation of the authentication token for logging purposes.
/// </summary>
/// <returns>The string representation of the authentication token</returns>
public override readonly string ToString()
=> $"AuthenticationToken(DisplayName=\"{DisplayName}\",ExpiresOn=\"{ExpiresOn}\",Token=\"{Token}\",UserId=\"{UserId}\")";
}
Loading

0 comments on commit 7640cbc

Please sign in to comment.