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
33 changes: 33 additions & 0 deletions .squad/agents/kaylee/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

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

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

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

### 2026-03-14 — Fix Copilot Review Issues on PR #70 and PR #71

**Status:** Complete

**PR #70 (feature/44-webapp-oidc):**
- Pinned Microsoft.Identity.Web packages to 4.5.0 (was floating `*`)
- Removed unused `OpenIdConnect` using from Program.cs
- Replaced hardcoded GUID fallback scope with startup config validation
- Handler now passes `cancellationToken` and propagates exceptions

**PR #71 (feature/45-maui-msal):**
- MsalAuthService reads TenantId, ClientId, RedirectUri, Scopes from IConfiguration
- Fixed IsSignedIn: `_cachedAccount` now updated on every successful token acquisition
- AuthenticatedHttpMessageHandler attempts token unconditionally (no IsSignedIn gate)
- Handler scopes also read from config instead of hardcoded GUIDs

### 2026-03-13 — MSAL.NET Authentication for MAUI Clients (#45)

**Status:** Complete
**Branch:** `feature/45-maui-msal`

Implemented MSAL.NET public client auth in `SentenceStudio.AppLib`:
- `IAuthService` interface + `MsalAuthService` (PKCE via system browser)
- `DevAuthService` no-op for local dev (config-driven via `Auth:UseEntraId`)
- `AuthenticatedHttpMessageHandler` wired into all HttpClient registrations
- MacCatalyst `Info.plist` updated with MSAL redirect URL scheme
- AppLib builds clean; full MacCatalyst build blocked by pre-existing UI error

### 2026-03-14 — WebApp OIDC Authentication (#44)

**Status:** Complete
Expand Down
45 changes: 45 additions & 0 deletions .squad/decisions/inbox/kaylee-maui-msal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Decision: MSAL.NET Authentication for MAUI Clients

**Date:** 2026-03-13
**Author:** Kaylee (Full-stack Dev)
**Issue:** #45
**Branch:** `feature/45-maui-msal`
**Status:** IMPLEMENTED

## Summary

Added MSAL.NET public client authentication to the MAUI native clients via `IAuthService` in `SentenceStudio.AppLib`.

## Key Decisions

1. **IAuthService abstraction** — All auth goes through `IAuthService` so the rest of the app never touches MSAL directly.
2. **MsalAuthService** — Uses `PublicClientApplicationBuilder` with the Native client registration (`68d5abeb-...`), PKCE via system browser, silent-first with interactive fallback.
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.
4. **AuthenticatedHttpMessageHandler** — DelegatingHandler wired into all `HttpClient` registrations (API clients + CoreSync). Attaches Bearer token when available, gracefully proceeds without it.
5. **Config-driven toggle** — `Auth:UseEntraId` (bool) in `IConfiguration` selects MSAL vs DevAuth, matching the same pattern used in Api and WebApp projects.
6. **MacCatalyst URL scheme** — `msal68d5abeb-9ca7-46cc-9572-42e33f15a0ba` registered in `Info.plist` for redirect URI callback.

## Files Changed

| File | Change |
|------|--------|
| `src/SentenceStudio.AppLib/SentenceStudio.AppLib.csproj` | Added `Microsoft.Identity.Client` NuGet |
| `src/SentenceStudio.AppLib/Services/IAuthService.cs` | New interface |
| `src/SentenceStudio.AppLib/Services/MsalAuthService.cs` | MSAL implementation |
| `src/SentenceStudio.AppLib/Services/DevAuthService.cs` | No-op dev implementation |
| `src/SentenceStudio.AppLib/Services/AuthenticatedHttpMessageHandler.cs` | Bearer token handler |
| `src/SentenceStudio.AppLib/ServiceCollectionExtentions.cs` | `AddAuthServices()` + handler wiring |
| `src/SentenceStudio.AppLib/Setup/SentenceStudioAppBuilder.cs` | Calls `AddAuthServices()` |
| `src/SentenceStudio.MacCatalyst/Platforms/MacCatalyst/Info.plist` | MSAL URL scheme |

## What's NOT Included (deliberate)

- No sign-in UI yet (that's a separate issue)
- No SecureStorage token cache (in-memory only for now)
- No Android manifest changes (MacCatalyst is primary dev target)
- No `appsettings.json` changes — `Auth:UseEntraId` defaults to `false` when absent
Comment on lines +35 to +40

## Risks

- Token cache is in-memory only — users re-authenticate on every app restart. SecureStorage integration is a follow-up.
- Pre-existing build error in `SentenceStudio.UI` (unrelated `DuplicateGroup` reference) blocks full MacCatalyst build. AppLib compiles clean.
1 change: 1 addition & 0 deletions src/SentenceStudio.AppLib/SentenceStudio.AppLib.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Identity.Client" Version="4.*" />
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CommunityToolkit.Maui" Version="13.0.0" />
Expand Down
31 changes: 27 additions & 4 deletions src/SentenceStudio.AppLib/ServiceCollectionExtentions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using CoreSync;
using CoreSync.Http.Client;
using CoreSync.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SentenceStudio.Services;
using SentenceStudio.Services.Api;
using SentenceStudio.Services.Agents;
using SentenceStudio.Shared.Models;
Expand Down Expand Up @@ -49,7 +51,8 @@ public static void AddSyncServices(this IServiceCollection services, string data
{
httpClient.BaseAddress = serverUri;
httpClient.Timeout = TimeSpan.FromMinutes(10);
});
})
.AddHttpMessageHandler<AuthenticatedHttpMessageHandler>();

services.AddCoreSyncHttpClient(options =>
{
Expand All @@ -58,11 +61,31 @@ public static void AddSyncServices(this IServiceCollection services, string data
});
}

public static IServiceCollection AddAuthServices(this IServiceCollection services, IConfiguration configuration)
{
var useEntraId = configuration.GetValue<bool>("Auth:UseEntraId");

if (useEntraId)
{
services.AddSingleton<IAuthService, MsalAuthService>();
}
else
{
services.AddSingleton<IAuthService, DevAuthService>();
}

services.AddTransient<AuthenticatedHttpMessageHandler>();
return services;
}

public static void AddApiClients(this IServiceCollection services, Uri baseUri)
{
services.AddHttpClient<IAiApiClient, AiApiClient>(client => client.BaseAddress = baseUri);
services.AddHttpClient<ISpeechApiClient, SpeechApiClient>(client => client.BaseAddress = baseUri);
services.AddHttpClient<IPlansApiClient, PlansApiClient>(client => client.BaseAddress = baseUri);
services.AddHttpClient<IAiApiClient, AiApiClient>(client => client.BaseAddress = baseUri)
.AddHttpMessageHandler<AuthenticatedHttpMessageHandler>();
services.AddHttpClient<ISpeechApiClient, SpeechApiClient>(client => client.BaseAddress = baseUri)
.AddHttpMessageHandler<AuthenticatedHttpMessageHandler>();
services.AddHttpClient<IPlansApiClient, PlansApiClient>(client => client.BaseAddress = baseUri)
.AddHttpMessageHandler<AuthenticatedHttpMessageHandler>();
services.AddSingleton<IAiGatewayClient, AiGatewayClient>();
services.AddSingleton<ISpeechGatewayClient, SpeechGatewayClient>();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace SentenceStudio.Services;

/// <summary>
/// Delegating handler that attaches a Bearer token to outgoing requests.
/// Attempts token acquisition unconditionally. If it returns null, the
/// request proceeds without an Authorization header so the server's
/// DevAuthHandler handles unauthenticated requests during development.
/// </summary>
public class AuthenticatedHttpMessageHandler : DelegatingHandler
{
private readonly string[] _defaultScopes;
private readonly IAuthService _authService;
private readonly ILogger<AuthenticatedHttpMessageHandler> _logger;

public AuthenticatedHttpMessageHandler(
IAuthService authService,
IConfiguration configuration,
ILogger<AuthenticatedHttpMessageHandler> logger)
{
_authService = authService;
_logger = logger;

_defaultScopes = configuration.GetSection("AzureAd:Scopes").Get<string[]>()
?? throw new InvalidOperationException(
"AzureAd:Scopes must be configured.");
}

protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
try
{
var token = await _authService.GetAccessTokenAsync(_defaultScopes);
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to attach Bearer token; proceeding without auth");
}

return await base.SendAsync(request, cancellationToken);
}
}
18 changes: 18 additions & 0 deletions src/SentenceStudio.AppLib/Services/DevAuthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.Identity.Client;

namespace SentenceStudio.Services;

/// <summary>
/// No-op auth service for local development. Always reports as signed in
/// so UI flows aren't blocked, but returns null tokens (the server's
/// DevAuthHandler takes care of creating a synthetic identity).
/// </summary>
public class DevAuthService : IAuthService
{
public bool IsSignedIn => true;
public string? UserName => "dev@localhost";

public Task<AuthenticationResult?> SignInAsync() => Task.FromResult<AuthenticationResult?>(null);
public Task SignOutAsync() => Task.CompletedTask;
public Task<string?> GetAccessTokenAsync(string[] scopes) => Task.FromResult<string?>(null);
}
12 changes: 12 additions & 0 deletions src/SentenceStudio.AppLib/Services/IAuthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.Identity.Client;

namespace SentenceStudio.Services;

public interface IAuthService
{
Task<AuthenticationResult?> SignInAsync();
Comment on lines +1 to +7
Task SignOutAsync();
Task<string?> GetAccessTokenAsync(string[] scopes);
bool IsSignedIn { get; }
string? UserName { get; }
}
125 changes: 125 additions & 0 deletions src/SentenceStudio.AppLib/Services/MsalAuthService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;

namespace SentenceStudio.Services;

public class MsalAuthService : IAuthService
{
private readonly string[] _defaultScopes;
private readonly IPublicClientApplication _pca;
private readonly ILogger<MsalAuthService> _logger;
private IAccount? _cachedAccount;

public bool IsSignedIn => _cachedAccount is not null;
public string? UserName => _cachedAccount?.Username;
Comment on lines +12 to +15

public MsalAuthService(IConfiguration configuration, ILogger<MsalAuthService> logger)
{
_logger = logger;

var tenantId = configuration["AzureAd:TenantId"]
?? throw new InvalidOperationException("AzureAd:TenantId must be configured.");
var clientId = configuration["AzureAd:ClientId"]
?? throw new InvalidOperationException("AzureAd:ClientId must be configured.");
var redirectUri = configuration["AzureAd:RedirectUri"]
?? $"msal{clientId}://auth";

_defaultScopes = configuration.GetSection("AzureAd:Scopes").Get<string[]>()
?? throw new InvalidOperationException(
"AzureAd:Scopes must be configured. Add an array of API scopes to appsettings.json or user-secrets.");

_pca = PublicClientApplicationBuilder
.Create(clientId)
.WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
.WithRedirectUri(redirectUri)
.Build();
}

public async Task<AuthenticationResult?> SignInAsync()
{
try
{
var result = await AcquireTokenAsync(_defaultScopes);
if (result is not null)
{
_cachedAccount = result.Account;
_logger.LogInformation("Signed in as {User}", result.Account.Username);
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Sign-in failed");
return null;
}
}

public async Task SignOutAsync()
{
try
{
var accounts = await _pca.GetAccountsAsync();
foreach (var account in accounts)
{
await _pca.RemoveAsync(account);
}
_cachedAccount = null;
_logger.LogInformation("Signed out");
}
catch (Exception ex)
{
_logger.LogError(ex, "Sign-out failed");
}
}

public async Task<string?> GetAccessTokenAsync(string[] scopes)
{
try
{
var result = await AcquireTokenAsync(scopes);
return result?.AccessToken;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to acquire access token");
return null;
}
}

private async Task<AuthenticationResult?> AcquireTokenAsync(string[] scopes)
{
// Try silent acquisition first
var accounts = await _pca.GetAccountsAsync();
var account = _cachedAccount ?? accounts.FirstOrDefault();

if (account is not null)
{
try
{
var result = await _pca.AcquireTokenSilent(scopes, account).ExecuteAsync();
_cachedAccount = result.Account;
return result;
}
catch (MsalUiRequiredException)
{
_logger.LogDebug("Silent token acquisition failed, falling back to interactive");
}
}

// Fall back to interactive (system browser with PKCE)
try
{
var result = await _pca.AcquireTokenInteractive(scopes)
.WithUseEmbeddedWebView(false)
.ExecuteAsync();
_cachedAccount = result.Account;
return result;
}
catch (MsalClientException ex) when (ex.ErrorCode == MsalError.AuthenticationCanceledError)
{
_logger.LogInformation("User cancelled authentication");
return null;
}
}
}
3 changes: 3 additions & 0 deletions src/SentenceStudio.AppLib/Setup/SentenceStudioAppBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public static MauiAppBuilder UseSentenceStudioApp(this MauiAppBuilder builder)

RegisterServices(builder.Services);

// Auth services (MSAL or DevAuth based on configuration)
builder.Services.AddAuthServices(builder.Configuration);

var openAiApiKey = (DeviceInfo.Idiom == DeviceIdiom.Desktop)
? Environment.GetEnvironmentVariable("AI__OpenAI__ApiKey")!
: builder.Configuration.GetRequiredSection("Settings").Get<Settings>().OpenAIKey;
Expand Down
11 changes: 11 additions & 0 deletions src/SentenceStudio.MacCatalyst/Platforms/MacCatalyst/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,16 @@
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.simplyprofound.sentencestudio.msalauth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>msal68d5abeb-9ca7-46cc-9572-42e33f15a0ba</string>
</array>
</dict>
</array>
</dict>
</plist>
Loading