Skip to content

Commit 7640cbc

Browse files
authored
(#96) authentication. (#97)
1 parent d502b80 commit 7640cbc

File tree

10 files changed

+704
-71
lines changed

10 files changed

+704
-71
lines changed

docs/content/in-depth/client/_index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ There are four ways to configure a HttpClient for communication with the datasyn
101101
}
102102
```
103103

104+
> [!TIP]
105+
> You can easily set up basic and bearer authentication when using `HttpClientOptions` using the `GenericAuthenticationProvider`.
106+
> See the [authentication guide](./auth.md) for more details.
107+
104108
You must configure one of these options so that the data synchronization services know which datasync service to communicate with.
105109

106110
### Configuring entities to synchronize

docs/content/in-depth/client/auth.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
+++
2+
title = "Authentication"
3+
weight = 30
4+
+++
5+
6+
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).
7+
8+
The `GenericAuthenticationProvider` and associated classes are in the `CommunityToolkit.Datasync.Client.Authentication` namespace.
9+
10+
## Set up authentication and authorization on the datasync service
11+
12+
You must set up authentication and authorization on the datasync service first. The authentication and authorization is regular ASP.NET Core
13+
identity, so [follow the instructions](https://learn.microsoft.com/aspnet/core/security/) for your particular provider.
14+
15+
## Create a method to retrieve the token
16+
17+
You need to implement a method to retrieve the token. Normally, this uses the library that is provided for the purpose. For example:
18+
19+
* Microsoft logins use [Microsoft.Identity.Client](https://www.nuget.org/packages/Microsoft.Identity.Client).
20+
* Other logins on MAUI may use [WebAuthenticator](https://learn.microsoft.com/dotnet/maui/platform-integration/communication/authentication)
21+
22+
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.
23+
24+
## Add the GenericAuthenticationProvider to your client
25+
26+
The `GenericAuthenticationProvider` takes a function that retrieves the token. For example:
27+
28+
```csharp
29+
public async Task<AuthenticationToken> GetTokenAsync(CancellationToken cancellationToken = default)
30+
{
31+
// Put the logic to retrieve the JWT here.
32+
33+
DateTimeOffset expiresOn = expiry-date;
34+
return new AuthenticationToken()
35+
{
36+
Token = "the JWT you need to pass to the service",
37+
UserId = "the user ID",
38+
DisplayName = "the display Name",
39+
ExpiresOn = expiresOn
40+
};
41+
}
42+
```
43+
44+
You can now create a GenericAuthenticationProvider:
45+
46+
```csharp
47+
GenericAuthenticationProvider authProvider = new(GetTokenAsync);
48+
```
49+
50+
### Build HttpClientOptions with the authentication provider
51+
52+
The authentication provider is a `DelegatingHandler`, so it belongs in the `HttpPipeline`:
53+
54+
```csharp
55+
HttpClientOptions options = new()
56+
{
57+
HttpPipeline = [ authProvider ],
58+
Endpont = "https://myservice.azurewebsites.net"
59+
};
60+
```
61+
62+
You can then use this options structure when constructing a client (either in the `OnDatasyncInitialization()` method or when constructing the `DatasyncServiceClient`).
63+
64+
> [!TIP]
65+
> It's normal to inject the authentication provider as a singleton in an MVVM scenario with dependency injection.
66+
67+
## Forcing a login request
68+
69+
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.
70+
71+
## Refresh token
72+
73+
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.
74+
75+
## Other options
76+
77+
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:
78+
79+
```csharp
80+
GenericAuthenticationProvider authProvider = new(GetTokenAsync, "X-ZUMO-AUTH");
81+
```
82+
83+
Similarly, you can specify the authentication type for the authorization header (instead of Bearer):
84+
85+
```csharp
86+
GenericAuthenticationProvider authProvider = new(GetTokenAsync, "Authorization", "Basic");
87+
```
88+
89+
This gives you significant flexibility to build the authentication mechanism appropriate for your application.
90+
91+
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:
92+
93+
```csharp
94+
GenericAuthenticationProvider authProvider = new(GetTokenAsync)
95+
{
96+
RefreshBufferTimeSpan = TimeSpan.FromSeconds(30)
97+
};
98+
```

docs/content/in-depth/client/oneline-operations.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ public IHttpClientFactory GetClientFactory()
5555

5656
The first element in the list becomes the root handler, then each successive handler is chained to the `InnerHandler` of the previous handler.
5757

58+
> [!TIP]
59+
> You can easily set up basic and bearer authentication using the `GenericAuthenticationProvider`. See the [authentication guide](./auth.md) for more details.
60+
5861
## Create a Datasync Service Client
5962

6063
Now that you have something to generate `HttpClient` objects, you can use it to create a `DatasyncServiceClient` for a specific service:

samples/todoapp/TodoApp.MAUI/MainPage.xaml.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5-
using System.Diagnostics;
65
using TodoApp.MAUI.Models;
76
using TodoApp.MAUI.ViewModels;
87

@@ -22,14 +21,14 @@ public MainPage()
2221
protected override void OnAppearing()
2322
{
2423
base.OnAppearing();
25-
this._viewModel.OnActivated();
24+
this._viewModel.RefreshItemsCommand.Execute();
2625
}
2726

2827
public void OnListItemTapped(object sender, ItemTappedEventArgs e)
2928
{
3029
if (e.Item is TodoItem item)
3130
{
32-
this._viewModel.SelectItemCommand.Execute(item);
31+
this._viewModel.UpdateItemCommand.Execute(item);
3332
}
3433

3534
if (sender is ListView itemList)

samples/todoapp/TodoApp.MAUI/TodoApp.MAUI.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
</ItemGroup>
6464

6565
<ItemGroup>
66+
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.0" />
6667
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
6768
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
6869
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />

samples/todoapp/TodoApp.MAUI/ViewModels/MainViewModel.cs

Lines changed: 13 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,22 @@
33
// See the LICENSE file in the project root for more information.
44

55
using CommunityToolkit.Datasync.Client;
6+
using CommunityToolkit.Mvvm.ComponentModel;
67
using Microsoft.EntityFrameworkCore;
7-
using System.ComponentModel;
8-
using System.Windows.Input;
98
using TodoApp.MAUI.Models;
109
using TodoApp.MAUI.Services;
1110

1211
namespace TodoApp.MAUI.ViewModels;
1312

14-
public class MainViewModel(AppDbContext context, IAlertService alertService) : INotifyPropertyChanged
13+
public class MainViewModel(AppDbContext context, IAlertService alertService) : ObservableRecipient
1514
{
15+
[ObservableProperty]
1616
private bool _isRefreshing = false;
1717

18-
public ICommand AddItemCommand
19-
=> new Command<Entry>(async (Entry entry) => await AddItemAsync(entry.Text));
18+
[ObservableProperty]
19+
private ConcurrentObservableCollection<TodoItem> items = [];
2020

21-
public ICommand RefreshItemsCommand
22-
=> new Command(async () => await RefreshItemsAsync());
23-
24-
public ICommand SelectItemCommand
25-
=> new Command<TodoItem>(async (TodoItem item) => await UpdateItemAsync(item.Id, !item.IsComplete));
26-
27-
public ConcurrentObservableCollection<TodoItem> Items { get; } = new();
28-
29-
public bool IsRefreshing
30-
{
31-
get => this._isRefreshing;
32-
set => SetProperty(ref this._isRefreshing, value, nameof(IsRefreshing));
33-
}
34-
35-
public async void OnActivated()
36-
{
37-
await RefreshItemsAsync();
38-
}
39-
40-
public async Task RefreshItemsAsync()
21+
public async Task RefreshItemsAsync(CancellationToken cancellationToken = default)
4122
{
4223
if (IsRefreshing)
4324
{
@@ -46,8 +27,8 @@ public async Task RefreshItemsAsync()
4627

4728
try
4829
{
49-
await context.SynchronizeAsync();
50-
List<TodoItem> items = await context.TodoItems.ToListAsync();
30+
await context.SynchronizeAsync(cancellationToken);
31+
List<TodoItem> items = await context.TodoItems.ToListAsync(cancellationToken);
5132
Items.ReplaceAll(items);
5233
}
5334
catch (Exception ex)
@@ -60,17 +41,17 @@ public async Task RefreshItemsAsync()
6041
}
6142
}
6243

63-
public async Task UpdateItemAsync(string itemId, bool isComplete)
44+
public async Task UpdateItemAsync(string itemId, CancellationToken cancellationToken = default)
6445
{
6546
try
6647
{
6748
TodoItem? item = await context.TodoItems.FindAsync([itemId]);
6849
if (item is not null)
6950
{
70-
item.IsComplete = isComplete;
51+
item.IsComplete = !item.IsComplete;
7152
_ = context.TodoItems.Update(item);
7253
_ = Items.ReplaceIf(x => x.Id == itemId, item);
73-
_ = await context.SaveChangesAsync();
54+
_ = await context.SaveChangesAsync(cancellationToken);
7455
}
7556
}
7657
catch (Exception ex)
@@ -79,54 +60,18 @@ public async Task UpdateItemAsync(string itemId, bool isComplete)
7960
}
8061
}
8162

82-
public async Task AddItemAsync(string text)
63+
public async Task AddItemAsync(string text, CancellationToken cancellationToken = default)
8364
{
8465
try
8566
{
8667
TodoItem item = new() { Title = text };
8768
_ = context.TodoItems.Add(item);
88-
_ = await context.SaveChangesAsync();
69+
_ = await context.SaveChangesAsync(cancellationToken);
8970
Items.Add(item);
9071
}
9172
catch (Exception ex)
9273
{
9374
await alertService.ShowErrorAlertAsync("AddItem", ex.Message);
9475
}
9576
}
96-
97-
#region INotifyPropertyChanged
98-
/// <summary>
99-
/// The event handler required by <see cref="INotifyPropertyChanged"/>
100-
/// </summary>
101-
public event PropertyChangedEventHandler? PropertyChanged;
102-
103-
/// <summary>
104-
/// Sets a backing store value and notify watchers of the change. The type must
105-
/// implement <see cref="IEquatable{T}"/> for proper comparisons.
106-
/// </summary>
107-
/// <typeparam name="T">The type of the value</typeparam>
108-
/// <param name="storage">The backing store</param>
109-
/// <param name="value">The new value</param>
110-
/// <param name="propertyName"></param>
111-
protected void SetProperty<T>(ref T storage, T value, string? propertyName = null) where T : notnull
112-
{
113-
if (!storage.Equals(value))
114-
{
115-
storage = value;
116-
NotifyPropertyChanged(propertyName);
117-
}
118-
}
119-
120-
/// <summary>
121-
/// Notifies the data context that the property named has changed value.
122-
/// </summary>
123-
/// <param name="propertyName">The name of the property</param>
124-
protected void NotifyPropertyChanged(string? propertyName = null)
125-
{
126-
if (propertyName != null)
127-
{
128-
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
129-
}
130-
}
131-
#endregion
13277
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace CommunityToolkit.Datasync.Client.Authentication;
6+
7+
/// <summary>
8+
/// Definition of an authentication provider, which is a specific type of delegating
9+
/// handler that handles authentication updates.
10+
/// </summary>
11+
public abstract class AuthenticationProvider : DelegatingHandler
12+
{
13+
/// <summary>
14+
/// The display name for the currently logged in user. This may be null.
15+
/// </summary>
16+
public string? DisplayName { get; protected set; }
17+
18+
/// <summary>
19+
/// If true, the user is logged in (and the UserId is available).
20+
/// </summary>
21+
public bool IsLoggedIn { get; protected set; }
22+
23+
/// <summary>
24+
/// The User ID for this user.
25+
/// </summary>
26+
public string? UserId { get; protected set; }
27+
28+
/// <summary>
29+
/// Initiate a login request out of band of the pipeline. This can be used to
30+
/// initiate the login process via a button.
31+
/// </summary>
32+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
33+
/// <returns>An async task that resolves when the login is complete.</returns>
34+
public abstract Task LoginAsync(CancellationToken cancellationToken = default);
35+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace CommunityToolkit.Datasync.Client.Authentication;
6+
7+
/// <summary>
8+
/// Definition of an authentication token response.
9+
/// </summary>
10+
public struct AuthenticationToken
11+
{
12+
/// <summary>
13+
/// The display name for this user.
14+
/// </summary>
15+
public string DisplayName { get; set; }
16+
17+
/// <summary>
18+
/// The expiry date of the JWT Token
19+
/// </summary>
20+
public DateTimeOffset ExpiresOn { get; set; }
21+
/// <summary>
22+
/// The actual JWT Token
23+
/// </summary>
24+
public string Token { get; set; }
25+
26+
/// <summary>
27+
/// The User Id for this user
28+
/// </summary>
29+
public string UserId { get; set; }
30+
31+
/// <summary>
32+
/// Return a visual representation of the authentication token for logging purposes.
33+
/// </summary>
34+
/// <returns>The string representation of the authentication token</returns>
35+
public override readonly string ToString()
36+
=> $"AuthenticationToken(DisplayName=\"{DisplayName}\",ExpiresOn=\"{ExpiresOn}\",Token=\"{Token}\",UserId=\"{UserId}\")";
37+
}

0 commit comments

Comments
 (0)