Compare commits

3 Commits

Author SHA1 Message Date
459079d708 Merge pull request 'refactor(auth): remove redundant SendWithRefreshAsync, enforce JwtTokenRefresher as sole handler' (#69) from feature/remove-sendwithrefreshasync into develop
Reviewed-on: #69
Reviewed-by: Fastwind <fastwind@noreply.localhost>
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 23:47:08 +00:00
REDCODE
220581579b refactor(auth): remove SendWithRefreshAsync, rely solely on JwtTokenRefresher
- Remove SendWithRefreshAsync method from AuthServiceBase (now dead code)
- Replace all 15 SendWithRefreshAsync call sites in 7 services with
  direct Client.XxxAsync calls
- Remove unused using System.Net from AuthServiceBase
- Update AGENTS.md to document JwtTokenRefresher as the sole token
  refresh mechanism
2026-07-10 01:12:31 +02:00
REDCODE
254e98b37b refactor(auth): extract AuthServiceBase, replace retry with re-auth on 401
- Move AuthServiceBase from ServiceBase.cs into its own file
- Replace FetchLoggedUserAsync retry loop with re-authentication on
  failure, then single recursive retry
- Fix override keyword order in App.razor OnInitializedAsync
- Clean up MainLayout.razor inject (unqualified type name)
2026-07-10 01:01:17 +02:00
13 changed files with 82 additions and 106 deletions

View File

@@ -34,7 +34,7 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (login) and one named `"MilkstreamClient"` with automatic JWT refresh.
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
## XML docs enforced

View File

@@ -18,7 +18,7 @@
@code {
bool _initialized;
protected override async Task OnInitializedAsync() {
protected async override Task OnInitializedAsync() {
await loginService.InitializeAsync();
_initialized = true;

View File

@@ -1,6 +1,6 @@
@inherits LayoutComponentBase
@inject NavigationManager NavigationManager
@inject MilkStream.Client.Services.LoginService LoginService
@inject LoginService LoginService
@implements IDisposable
<NavMenu/>

View File

@@ -24,7 +24,7 @@ public sealed class AlbumService(
if (!string.IsNullOrEmpty(search))
url += $"&search={Uri.EscapeDataString(search)}";
var response = await SendWithRefreshAsync(() => Client.GetAsync(url));
var response = await Client.GetAsync(url);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<List<AlbumPreviewDto>>();
@@ -38,7 +38,7 @@ public sealed class AlbumService(
/// <param name="id">The album ID.</param>
/// <returns>The full album details, or null if not found.</returns>
public async Task<AlbumFullDto?> GetAlbumAsync(Guid id) {
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/album/{id}"));
var response = await Client.GetAsync($"/api/album/{id}");
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<AlbumFullDto>();
@@ -51,7 +51,7 @@ public sealed class AlbumService(
/// </summary>
/// <returns>A list of unassigned album previews, or null if the request failed.</returns>
public async Task<List<AlbumPreviewDto>?> GetUnassignedAsync() {
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/album?unassigned=true&pageSize=999"));
var response = await Client.GetAsync("/api/album?unassigned=true&pageSize=999");
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<List<AlbumPreviewDto>>();

View File

@@ -31,7 +31,7 @@ public sealed class AssetService(
if (seed.HasValue)
url += $"&seed={seed.Value}";
var response = await SendWithRefreshAsync(() => Client.GetAsync(url));
var response = await Client.GetAsync(url);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<List<AssetPreviewDto>>();

View File

@@ -0,0 +1,51 @@
using Microsoft.Extensions.Options;
namespace MilkStream.Client.Services;
/// <summary>
/// Abstract class for services that require authentication. Builds on top of ServiceBase.
/// </summary>
public abstract class AuthServiceBase : ServiceBase {
/// <summary>
/// Gets the HTTP client for making API requests.
/// </summary>
protected override HttpClient Client { get; init; }
/// <summary>
/// Gets the login service for authentication operations.
/// </summary>
protected LoginService LoginService { get; }
/// <summary>
/// Initializes a new instance of the <see cref="AuthServiceBase"/> class with the specified options, HTTP client factory, and login service connection.
/// </summary>
/// <param name="options">Service configuration options.</param>
/// <param name="httpClientFactory">Factory for creating HTTP clients.</param>
/// <param name="loginService">The login service for authentication.</param>
/// <param name="logger">Logger instance.</param>
protected AuthServiceBase(
IOptions<ServiceOptions> options,
IHttpClientFactory httpClientFactory,
LoginService loginService,
ILogger logger
) : base(options, httpClientFactory, logger) {
LoginService = loginService;
Client = httpClientFactory.CreateClient("MilkstreamClient");
Client.BaseAddress = new Uri(options.Value.BaseUrl);
// Subscribe to the AuthInfoChanged event to set the authorization header when the auth info changes.
loginService.AuthInfoChanged += SetAuthorizationHeader;
// Set the initial authorization header and base address.
SetAuthorizationHeader(null, loginService.AuthInfo);
}
/// <summary>
/// Handles the AuthInfoChanged event to update the Authorization header for the HTTP client.
/// </summary>
/// <param name="sender"></param>
/// <param name="authInfo"></param>
protected virtual void SetAuthorizationHeader(object? sender, AuthInfo? authInfo) {
if (authInfo == null) //no authentication available = logged out
Client.DefaultRequestHeaders.Authorization = null;
else Client.DefaultRequestHeaders.Authorization = new("Bearer", authInfo.Token);
}
}

View File

@@ -26,7 +26,7 @@ public sealed class FoldersService(
/// </summary>
/// <returns>A list of folders, or null if the request failed.</returns>
public async Task<List<FolderFullDto>?> GetAllFolders() {
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/folder"));
var response = await Client.GetAsync("/api/folder");
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<List<FolderFullDto>>();
@@ -39,7 +39,7 @@ public sealed class FoldersService(
/// <param name="id">The folder ID.</param>
/// <returns>The folder details, or null if not found.</returns>
public async Task<FolderFullDto?> GetFolderById(Guid id) {
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/folder/{id}"));
var response = await Client.GetAsync($"/api/folder/{id}");
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<FolderFullDto>();
@@ -81,7 +81,7 @@ public sealed class FoldersService(
/// <param name="count">Number of sample paths to return (default 10).</param>
/// <returns>A sample paths DTO, or null if the request failed.</returns>
public async Task<FolderSamplePathsDto?> GetSamplePathsAsync(Guid folderId, int count = 10) {
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/folder/{folderId}/sample-paths?count={count}"));
var response = await Client.GetAsync($"/api/folder/{folderId}/sample-paths?count={count}");
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<FolderSamplePathsDto>();

View File

@@ -18,7 +18,7 @@ public sealed class JobsService(
/// Returns active root jobs only (lightweight, polled frequently).
/// </summary>
public async Task<List<JobStatusDto>> GetActiveRootJobs() {
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/jobs"));
var response = await Client.GetAsync("/api/jobs");
response.EnsureSuccessStatusCode();
var jobs = await response.Content.ReadFromJsonAsync<List<JobStatusDto>>();
return jobs ?? new List<JobStatusDto>();
@@ -28,7 +28,7 @@ public sealed class JobsService(
/// Returns a page of past root jobs.
/// </summary>
public async Task<(List<JobStatusDto> Jobs, int Total)> GetPastRootJobs(int page, int pageSize) {
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/jobs/past?page={page}&pageSize={pageSize}"));
var response = await Client.GetAsync($"/api/jobs/past?page={page}&pageSize={pageSize}");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<PastJobsResponse>();
return (result?.Jobs ?? [], result?.Total ?? 0);
@@ -38,7 +38,7 @@ public sealed class JobsService(
/// Returns children of a specific parent job.
/// </summary>
public async Task<List<JobStatusDto>> GetChildren(Guid parentId) {
var response = await SendWithRefreshAsync(() => Client.GetAsync($"/api/jobs/{parentId}/children"));
var response = await Client.GetAsync($"/api/jobs/{parentId}/children");
response.EnsureSuccessStatusCode();
var jobs = await response.Content.ReadFromJsonAsync<List<JobStatusDto>>();
return jobs ?? new List<JobStatusDto>();

View File

@@ -154,7 +154,7 @@ public sealed class LoginService : ServiceBase {
var info = authResult!.ToAuthInfo();
AuthInfo = info;
await PersistAuthAsync(info);
LoggedUser = await FetchLoggedUserAsync(maxRetries: 1);
LoggedUser = await FetchLoggedUserAsync();
return (true, null, info);
}
@@ -197,7 +197,7 @@ public sealed class LoginService : ServiceBase {
NotifyAuthInfoChanged();
if (LoggedUser == null)
LoggedUser = await FetchLoggedUserAsync(maxRetries: 1);
LoggedUser = await FetchLoggedUserAsync();
return authInfo;
}
@@ -264,26 +264,19 @@ public sealed class LoginService : ServiceBase {
#region Private Helpers
async Task<UserInfoDto?> FetchLoggedUserAsync(int maxRetries = 3) {
async Task<UserInfoDto?> FetchLoggedUserAsync() {
if (AuthInfo?.UserId == null || string.IsNullOrEmpty(AuthInfo.Token))
return null;
for (int attempt = 0; attempt < maxRetries; attempt++) {
try {
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/user/{AuthInfo.UserId}");
request.Headers.Authorization = new("Bearer", AuthInfo.Token);
var response = await Client.SendAsync(request);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<UserInfoDto>();
} catch {
logger.LogWarning("FetchLoggedUserAsync attempt {Attempt} failed", attempt + 1);
}
if (attempt < maxRetries - 1)
await Task.Delay(TimeSpan.FromMilliseconds(300 * (attempt + 1)));
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/user/{AuthInfo.UserId}");
request.Headers.Authorization = new("Bearer", AuthInfo.Token);
var response = await Client.SendAsync(request);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<UserInfoDto>();
else {
var info = await Reauthenticate();
return info != null ? await FetchLoggedUserAsync() : null;
}
return null;
}
async Task PersistAuthAsync(AuthInfo auth) {

View File

@@ -17,7 +17,7 @@ public sealed class PersonService(
/// </summary>
/// <returns>A list of person previews, or null if the request failed.</returns>
public async Task<List<PersonPreviewDto>?> GetAllAsync() {
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/person"));
var response = await Client.GetAsync("/api/person");
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<List<PersonPreviewDto>>();
@@ -37,7 +37,7 @@ public sealed class PersonService(
if (albumPage.HasValue && albumPageSize.HasValue)
url += $"?page={albumPage}&pageSize={albumPageSize}";
var response = await SendWithRefreshAsync(() => Client.GetAsync(url));
var response = await Client.GetAsync(url);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<PersonDetailedDto>();
@@ -61,7 +61,7 @@ public sealed class PersonService(
/// <param name="dto">The person creation data.</param>
/// <returns>The new person's ID, or null if the request failed.</returns>
public async Task<Guid?> CreatePersonAsync(PersonCreateDto dto) {
var response = await SendWithRefreshAsync(() => Client.PutAsJsonAsync("/api/person", dto));
var response = await Client.PutAsJsonAsync("/api/person", dto);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<Guid>();

View File

@@ -1,5 +1,4 @@
using Microsoft.Extensions.Options;
using System.Net;
namespace MilkStream.Client.Services;
@@ -41,71 +40,4 @@ public abstract class ServiceBase {
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")
);
}
}
/// <summary>
/// Abstract class for services that require authentication. Builds on top of ServiceBase.
/// </summary>
public abstract class AuthServiceBase : ServiceBase {
/// <summary>
/// Gets the HTTP client for making API requests.
/// </summary>
protected override HttpClient Client { get; init; }
/// <summary>
/// Gets the login service for authentication operations.
/// </summary>
protected LoginService LoginService { get; }
/// <summary>
/// Initializes a new instance of the <see cref="AuthServiceBase"/> class with the specified options, HTTP client factory, and login service connection.
/// </summary>
/// <param name="options">Service configuration options.</param>
/// <param name="httpClientFactory">Factory for creating HTTP clients.</param>
/// <param name="loginService">The login service for authentication.</param>
/// <param name="logger">Logger instance.</param>
protected AuthServiceBase(
IOptions<ServiceOptions> options,
IHttpClientFactory httpClientFactory,
LoginService loginService,
ILogger logger
) : base(options, httpClientFactory, logger) {
LoginService = loginService;
Client = httpClientFactory.CreateClient("MilkstreamClient");
Client.BaseAddress = new Uri(options.Value.BaseUrl);
// Subscribe to the AuthInfoChanged event to set the authorization header when the auth info changes.
loginService.AuthInfoChanged += SetAuthorizationHeader;
// Set the initial authorization header and base address.
SetAuthorizationHeader(null, loginService.AuthInfo);
}
/// <summary>
/// Sends an HTTP request, transparently refreshing the auth token and retrying once if the server returns 401.
/// </summary>
/// <param name="send">A factory that creates and sends the request. Called again on retry.</param>
/// <returns>The HTTP response message.</returns>
protected async Task<HttpResponseMessage> SendWithRefreshAsync(Func<Task<HttpResponseMessage>> send) {
var response = await send();
if (response.StatusCode != HttpStatusCode.Unauthorized)
return response;
response.Dispose();
var auth = await LoginService.Reauthenticate();
if (auth == null)
return new HttpResponseMessage(HttpStatusCode.Unauthorized);
Client.DefaultRequestHeaders.Authorization = new("Bearer", auth.Token);
return await send();
}
/// <summary>
/// Handles the AuthInfoChanged event to update the Authorization header for the HTTP client.
/// </summary>
/// <param name="sender"></param>
/// <param name="authInfo"></param>
protected virtual void SetAuthorizationHeader(object? sender, AuthInfo? authInfo) {
if (authInfo == null) //no authentication available = logged out
Client.DefaultRequestHeaders.Authorization = null;
else Client.DefaultRequestHeaders.Authorization = new("Bearer", authInfo.Token);
}
}
}

View File

@@ -31,7 +31,7 @@ public sealed class SettingsService : AuthServiceBase {
/// </summary>
/// <returns>A list of settings, or null if the request failed.</returns>
public async Task<List<SettingDto>?> GetAllSettings() {
var response = await SendWithRefreshAsync(() => Client.GetAsync("api/settings"));
var response = await Client.GetAsync("api/settings");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<SettingDto>>();
}

View File

@@ -17,7 +17,7 @@ public sealed class StatsService(
/// </summary>
/// <returns>A <see cref="StatsDto"/> with all gathered statistics, or null if the request failed.</returns>
public async Task<StatsDto?> GetStatsAsync() {
var response = await SendWithRefreshAsync(() => Client.GetAsync("/api/stats"));
var response = await Client.GetAsync("/api/stats");
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<StatsDto>();