Skip to content

Commit 4ed8059

Browse files
authored
Merge pull request #71 from davidortinau/feature/45-maui-msal
feat: add MSAL.NET authentication to MAUI clients (#45)
2 parents 7533c1d + 5ba4189 commit 4ed8059

File tree

10 files changed

+329
-4
lines changed

10 files changed

+329
-4
lines changed

.squad/agents/kaylee/history.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99

1010
<!-- Append new learnings below. Each entry is something lasting about the project. -->
1111

12+
- `SentenceStudio.AppLib` has `ImplicitUsings>disable` — must add explicit `using` for System.Net.Http, etc.
13+
- `Auth:UseEntraId` config flag pattern works across Api, WebApp, and now MAUI clients
14+
- MSAL.NET `WithBroker(BrokerOptions)` overload removed in v4.x — just omit it when not using a broker
15+
- `AuthenticatedHttpMessageHandler` is wired into all HttpClient registrations (API + CoreSync) via `AddHttpMessageHandler<T>()`
16+
- Pre-existing build error: `DuplicateGroup` missing in `SentenceStudio.UI/Pages/Vocabulary.razor` — blocks MacCatalyst full build
1217
- `Auth:UseEntraId` config flag controls auth mode in both API and WebApp — false = DevAuthHandler, true = Entra ID OIDC
1318
- Microsoft.Identity.Web OIDC uses `AddMicrosoftIdentityWebApp()` + `EnableTokenAcquisitionToCallDownstreamApi()` chain
1419
- Redis-backed distributed token cache via `Aspire.StackExchange.Redis.DistributedCaching` (match AppHost Aspire version for preview packages)
@@ -47,6 +52,34 @@
4752

4853
**Critical Path:** CoreSync SQLite→PostgreSQL migration (#55, XL).
4954

55+
### 2026-03-14 — Fix Copilot Review Issues on PR #70 and PR #71
56+
57+
**Status:** Complete
58+
59+
**PR #70 (feature/44-webapp-oidc):**
60+
- Pinned Microsoft.Identity.Web packages to 4.5.0 (was floating `*`)
61+
- Removed unused `OpenIdConnect` using from Program.cs
62+
- Replaced hardcoded GUID fallback scope with startup config validation
63+
- Handler now passes `cancellationToken` and propagates exceptions
64+
65+
**PR #71 (feature/45-maui-msal):**
66+
- MsalAuthService reads TenantId, ClientId, RedirectUri, Scopes from IConfiguration
67+
- Fixed IsSignedIn: `_cachedAccount` now updated on every successful token acquisition
68+
- AuthenticatedHttpMessageHandler attempts token unconditionally (no IsSignedIn gate)
69+
- Handler scopes also read from config instead of hardcoded GUIDs
70+
71+
### 2026-03-13 — MSAL.NET Authentication for MAUI Clients (#45)
72+
73+
**Status:** Complete
74+
**Branch:** `feature/45-maui-msal`
75+
76+
Implemented MSAL.NET public client auth in `SentenceStudio.AppLib`:
77+
- `IAuthService` interface + `MsalAuthService` (PKCE via system browser)
78+
- `DevAuthService` no-op for local dev (config-driven via `Auth:UseEntraId`)
79+
- `AuthenticatedHttpMessageHandler` wired into all HttpClient registrations
80+
- MacCatalyst `Info.plist` updated with MSAL redirect URL scheme
81+
- AppLib builds clean; full MacCatalyst build blocked by pre-existing UI error
82+
5083
### 2026-03-14 — WebApp OIDC Authentication (#44)
5184

5285
**Status:** Complete
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Decision: MSAL.NET Authentication for MAUI Clients
2+
3+
**Date:** 2026-03-13
4+
**Author:** Kaylee (Full-stack Dev)
5+
**Issue:** #45
6+
**Branch:** `feature/45-maui-msal`
7+
**Status:** IMPLEMENTED
8+
9+
## Summary
10+
11+
Added MSAL.NET public client authentication to the MAUI native clients via `IAuthService` in `SentenceStudio.AppLib`.
12+
13+
## Key Decisions
14+
15+
1. **IAuthService abstraction** — All auth goes through `IAuthService` so the rest of the app never touches MSAL directly.
16+
2. **MsalAuthService** — Uses `PublicClientApplicationBuilder` with the Native client registration (`68d5abeb-...`), PKCE via system browser, silent-first with interactive fallback.
17+
3. **DevAuthService** — No-op implementation for local dev (`Auth:UseEntraId` = false). Reports `IsSignedIn = true` and returns null tokens so UI isn't blocked and the server's DevAuthHandler handles unauthenticated requests.
18+
4. **AuthenticatedHttpMessageHandler** — DelegatingHandler wired into all `HttpClient` registrations (API clients + CoreSync). Attaches Bearer token when available, gracefully proceeds without it.
19+
5. **Config-driven toggle**`Auth:UseEntraId` (bool) in `IConfiguration` selects MSAL vs DevAuth, matching the same pattern used in Api and WebApp projects.
20+
6. **MacCatalyst URL scheme**`msal68d5abeb-9ca7-46cc-9572-42e33f15a0ba` registered in `Info.plist` for redirect URI callback.
21+
22+
## Files Changed
23+
24+
| File | Change |
25+
|------|--------|
26+
| `src/SentenceStudio.AppLib/SentenceStudio.AppLib.csproj` | Added `Microsoft.Identity.Client` NuGet |
27+
| `src/SentenceStudio.AppLib/Services/IAuthService.cs` | New interface |
28+
| `src/SentenceStudio.AppLib/Services/MsalAuthService.cs` | MSAL implementation |
29+
| `src/SentenceStudio.AppLib/Services/DevAuthService.cs` | No-op dev implementation |
30+
| `src/SentenceStudio.AppLib/Services/AuthenticatedHttpMessageHandler.cs` | Bearer token handler |
31+
| `src/SentenceStudio.AppLib/ServiceCollectionExtentions.cs` | `AddAuthServices()` + handler wiring |
32+
| `src/SentenceStudio.AppLib/Setup/SentenceStudioAppBuilder.cs` | Calls `AddAuthServices()` |
33+
| `src/SentenceStudio.MacCatalyst/Platforms/MacCatalyst/Info.plist` | MSAL URL scheme |
34+
35+
## What's NOT Included (deliberate)
36+
37+
- No sign-in UI yet (that's a separate issue)
38+
- No SecureStorage token cache (in-memory only for now)
39+
- No Android manifest changes (MacCatalyst is primary dev target)
40+
- No `appsettings.json` changes — `Auth:UseEntraId` defaults to `false` when absent
41+
42+
## Risks
43+
44+
- Token cache is in-memory only — users re-authenticate on every app restart. SecureStorage integration is a follow-up.
45+
- Pre-existing build error in `SentenceStudio.UI` (unrelated `DuplicateGroup` reference) blocks full MacCatalyst build. AppLib compiles clean.

src/SentenceStudio.AppLib/SentenceStudio.AppLib.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
</ItemGroup>
3737

3838
<ItemGroup>
39+
<PackageReference Include="Microsoft.Identity.Client" Version="4.*" />
3940
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
4041
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
4142
<PackageReference Include="CommunityToolkit.Maui" Version="13.0.0" />

src/SentenceStudio.AppLib/ServiceCollectionExtentions.cs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using CoreSync;
22
using CoreSync.Http.Client;
33
using CoreSync.Sqlite;
4+
using Microsoft.Extensions.Configuration;
45
using Microsoft.Extensions.DependencyInjection;
56
using Microsoft.Extensions.Logging;
7+
using SentenceStudio.Services;
68
using SentenceStudio.Services.Api;
79
using SentenceStudio.Services.Agents;
810
using SentenceStudio.Shared.Models;
@@ -49,7 +51,8 @@ public static void AddSyncServices(this IServiceCollection services, string data
4951
{
5052
httpClient.BaseAddress = serverUri;
5153
httpClient.Timeout = TimeSpan.FromMinutes(10);
52-
});
54+
})
55+
.AddHttpMessageHandler<AuthenticatedHttpMessageHandler>();
5356

5457
services.AddCoreSyncHttpClient(options =>
5558
{
@@ -58,11 +61,31 @@ public static void AddSyncServices(this IServiceCollection services, string data
5861
});
5962
}
6063

64+
public static IServiceCollection AddAuthServices(this IServiceCollection services, IConfiguration configuration)
65+
{
66+
var useEntraId = configuration.GetValue<bool>("Auth:UseEntraId");
67+
68+
if (useEntraId)
69+
{
70+
services.AddSingleton<IAuthService, MsalAuthService>();
71+
}
72+
else
73+
{
74+
services.AddSingleton<IAuthService, DevAuthService>();
75+
}
76+
77+
services.AddTransient<AuthenticatedHttpMessageHandler>();
78+
return services;
79+
}
80+
6181
public static void AddApiClients(this IServiceCollection services, Uri baseUri)
6282
{
63-
services.AddHttpClient<IAiApiClient, AiApiClient>(client => client.BaseAddress = baseUri);
64-
services.AddHttpClient<ISpeechApiClient, SpeechApiClient>(client => client.BaseAddress = baseUri);
65-
services.AddHttpClient<IPlansApiClient, PlansApiClient>(client => client.BaseAddress = baseUri);
83+
services.AddHttpClient<IAiApiClient, AiApiClient>(client => client.BaseAddress = baseUri)
84+
.AddHttpMessageHandler<AuthenticatedHttpMessageHandler>();
85+
services.AddHttpClient<ISpeechApiClient, SpeechApiClient>(client => client.BaseAddress = baseUri)
86+
.AddHttpMessageHandler<AuthenticatedHttpMessageHandler>();
87+
services.AddHttpClient<IPlansApiClient, PlansApiClient>(client => client.BaseAddress = baseUri)
88+
.AddHttpMessageHandler<AuthenticatedHttpMessageHandler>();
6689
services.AddSingleton<IAiGatewayClient, AiGatewayClient>();
6790
services.AddSingleton<ISpeechGatewayClient, SpeechGatewayClient>();
6891
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using System.Net.Http;
2+
using System.Net.Http.Headers;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Microsoft.Extensions.Configuration;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace SentenceStudio.Services;
9+
10+
/// <summary>
11+
/// Delegating handler that attaches a Bearer token to outgoing requests.
12+
/// Attempts token acquisition unconditionally. If it returns null, the
13+
/// request proceeds without an Authorization header so the server's
14+
/// DevAuthHandler handles unauthenticated requests during development.
15+
/// </summary>
16+
public class AuthenticatedHttpMessageHandler : DelegatingHandler
17+
{
18+
private readonly string[] _defaultScopes;
19+
private readonly IAuthService _authService;
20+
private readonly ILogger<AuthenticatedHttpMessageHandler> _logger;
21+
22+
public AuthenticatedHttpMessageHandler(
23+
IAuthService authService,
24+
IConfiguration configuration,
25+
ILogger<AuthenticatedHttpMessageHandler> logger)
26+
{
27+
_authService = authService;
28+
_logger = logger;
29+
30+
_defaultScopes = configuration.GetSection("AzureAd:Scopes").Get<string[]>()
31+
?? throw new InvalidOperationException(
32+
"AzureAd:Scopes must be configured.");
33+
}
34+
35+
protected override async Task<HttpResponseMessage> SendAsync(
36+
HttpRequestMessage request, CancellationToken cancellationToken)
37+
{
38+
try
39+
{
40+
var token = await _authService.GetAccessTokenAsync(_defaultScopes);
41+
if (!string.IsNullOrEmpty(token))
42+
{
43+
request.Headers.Authorization =
44+
new AuthenticationHeaderValue("Bearer", token);
45+
}
46+
}
47+
catch (Exception ex)
48+
{
49+
_logger.LogWarning(ex, "Failed to attach Bearer token; proceeding without auth");
50+
}
51+
52+
return await base.SendAsync(request, cancellationToken);
53+
}
54+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using Microsoft.Identity.Client;
2+
3+
namespace SentenceStudio.Services;
4+
5+
/// <summary>
6+
/// No-op auth service for local development. Always reports as signed in
7+
/// so UI flows aren't blocked, but returns null tokens (the server's
8+
/// DevAuthHandler takes care of creating a synthetic identity).
9+
/// </summary>
10+
public class DevAuthService : IAuthService
11+
{
12+
public bool IsSignedIn => true;
13+
public string? UserName => "dev@localhost";
14+
15+
public Task<AuthenticationResult?> SignInAsync() => Task.FromResult<AuthenticationResult?>(null);
16+
public Task SignOutAsync() => Task.CompletedTask;
17+
public Task<string?> GetAccessTokenAsync(string[] scopes) => Task.FromResult<string?>(null);
18+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Microsoft.Identity.Client;
2+
3+
namespace SentenceStudio.Services;
4+
5+
public interface IAuthService
6+
{
7+
Task<AuthenticationResult?> SignInAsync();
8+
Task SignOutAsync();
9+
Task<string?> GetAccessTokenAsync(string[] scopes);
10+
bool IsSignedIn { get; }
11+
string? UserName { get; }
12+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using Microsoft.Extensions.Configuration;
2+
using Microsoft.Extensions.Logging;
3+
using Microsoft.Identity.Client;
4+
5+
namespace SentenceStudio.Services;
6+
7+
public class MsalAuthService : IAuthService
8+
{
9+
private readonly string[] _defaultScopes;
10+
private readonly IPublicClientApplication _pca;
11+
private readonly ILogger<MsalAuthService> _logger;
12+
private IAccount? _cachedAccount;
13+
14+
public bool IsSignedIn => _cachedAccount is not null;
15+
public string? UserName => _cachedAccount?.Username;
16+
17+
public MsalAuthService(IConfiguration configuration, ILogger<MsalAuthService> logger)
18+
{
19+
_logger = logger;
20+
21+
var tenantId = configuration["AzureAd:TenantId"]
22+
?? throw new InvalidOperationException("AzureAd:TenantId must be configured.");
23+
var clientId = configuration["AzureAd:ClientId"]
24+
?? throw new InvalidOperationException("AzureAd:ClientId must be configured.");
25+
var redirectUri = configuration["AzureAd:RedirectUri"]
26+
?? $"msal{clientId}://auth";
27+
28+
_defaultScopes = configuration.GetSection("AzureAd:Scopes").Get<string[]>()
29+
?? throw new InvalidOperationException(
30+
"AzureAd:Scopes must be configured. Add an array of API scopes to appsettings.json or user-secrets.");
31+
32+
_pca = PublicClientApplicationBuilder
33+
.Create(clientId)
34+
.WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
35+
.WithRedirectUri(redirectUri)
36+
.Build();
37+
}
38+
39+
public async Task<AuthenticationResult?> SignInAsync()
40+
{
41+
try
42+
{
43+
var result = await AcquireTokenAsync(_defaultScopes);
44+
if (result is not null)
45+
{
46+
_cachedAccount = result.Account;
47+
_logger.LogInformation("Signed in as {User}", result.Account.Username);
48+
}
49+
return result;
50+
}
51+
catch (Exception ex)
52+
{
53+
_logger.LogError(ex, "Sign-in failed");
54+
return null;
55+
}
56+
}
57+
58+
public async Task SignOutAsync()
59+
{
60+
try
61+
{
62+
var accounts = await _pca.GetAccountsAsync();
63+
foreach (var account in accounts)
64+
{
65+
await _pca.RemoveAsync(account);
66+
}
67+
_cachedAccount = null;
68+
_logger.LogInformation("Signed out");
69+
}
70+
catch (Exception ex)
71+
{
72+
_logger.LogError(ex, "Sign-out failed");
73+
}
74+
}
75+
76+
public async Task<string?> GetAccessTokenAsync(string[] scopes)
77+
{
78+
try
79+
{
80+
var result = await AcquireTokenAsync(scopes);
81+
return result?.AccessToken;
82+
}
83+
catch (Exception ex)
84+
{
85+
_logger.LogWarning(ex, "Failed to acquire access token");
86+
return null;
87+
}
88+
}
89+
90+
private async Task<AuthenticationResult?> AcquireTokenAsync(string[] scopes)
91+
{
92+
// Try silent acquisition first
93+
var accounts = await _pca.GetAccountsAsync();
94+
var account = _cachedAccount ?? accounts.FirstOrDefault();
95+
96+
if (account is not null)
97+
{
98+
try
99+
{
100+
var result = await _pca.AcquireTokenSilent(scopes, account).ExecuteAsync();
101+
_cachedAccount = result.Account;
102+
return result;
103+
}
104+
catch (MsalUiRequiredException)
105+
{
106+
_logger.LogDebug("Silent token acquisition failed, falling back to interactive");
107+
}
108+
}
109+
110+
// Fall back to interactive (system browser with PKCE)
111+
try
112+
{
113+
var result = await _pca.AcquireTokenInteractive(scopes)
114+
.WithUseEmbeddedWebView(false)
115+
.ExecuteAsync();
116+
_cachedAccount = result.Account;
117+
return result;
118+
}
119+
catch (MsalClientException ex) when (ex.ErrorCode == MsalError.AuthenticationCanceledError)
120+
{
121+
_logger.LogInformation("User cancelled authentication");
122+
return null;
123+
}
124+
}
125+
}

src/SentenceStudio.AppLib/Setup/SentenceStudioAppBuilder.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ public static MauiAppBuilder UseSentenceStudioApp(this MauiAppBuilder builder)
3131

3232
RegisterServices(builder.Services);
3333

34+
// Auth services (MSAL or DevAuth based on configuration)
35+
builder.Services.AddAuthServices(builder.Configuration);
36+
3437
var openAiApiKey = (DeviceInfo.Idiom == DeviceIdiom.Desktop)
3538
? Environment.GetEnvironmentVariable("AI__OpenAI__ApiKey")!
3639
: builder.Configuration.GetRequiredSection("Settings").Get<Settings>().OpenAIKey;

src/SentenceStudio.MacCatalyst/Platforms/MacCatalyst/Info.plist

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,16 @@
3636
</array>
3737
<key>XSAppIconAssets</key>
3838
<string>Assets.xcassets/appicon.appiconset</string>
39+
<key>CFBundleURLTypes</key>
40+
<array>
41+
<dict>
42+
<key>CFBundleURLName</key>
43+
<string>com.simplyprofound.sentencestudio.msalauth</string>
44+
<key>CFBundleURLSchemes</key>
45+
<array>
46+
<string>msal68d5abeb-9ca7-46cc-9572-42e33f15a0ba</string>
47+
</array>
48+
</dict>
49+
</array>
3950
</dict>
4051
</plist>

0 commit comments

Comments
 (0)