diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/ICurrentUserService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/ICurrentUserService.cs
new file mode 100644
index 0000000000..b32c14fe53
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/ICurrentUserService.cs
@@ -0,0 +1,12 @@
+using System.Security.Claims;
+using FSH.Framework.Core.Context;
+
+namespace FSH.Modules.Identity.Contracts.Services;
+
+///
+/// Service interface for managing the current user context.
+/// Combines user identity access with initialization capabilities.
+///
+public interface ICurrentUserService : ICurrentUser, ICurrentUserInitializer
+{
+}
diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IRequestContextService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IRequestContextService.cs
new file mode 100644
index 0000000000..3bf9966703
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IRequestContextService.cs
@@ -0,0 +1,11 @@
+using FSH.Framework.Core.Context;
+
+namespace FSH.Modules.Identity.Contracts.Services;
+
+///
+/// Service interface for accessing HTTP request context information.
+/// Provides request metadata for auditing, logging, and other cross-cutting concerns.
+///
+public interface IRequestContextService : IRequestContext
+{
+}
diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs
index da3ea97e04..c976615f2f 100644
--- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs
+++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs
@@ -73,10 +73,12 @@ public void ConfigureServices(IHostApplicationBuilder builder)
ArgumentNullException.ThrowIfNull(builder);
var services = builder.Services;
services.AddSingleton();
- services.AddScoped();
- services.AddScoped();
+ services.AddScoped();
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped();
+ services.AddScoped(sp => sp.GetRequiredService());
services.AddScoped();
- services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService());
// User services - focused single-responsibility services
services.AddTransient();
@@ -111,8 +113,9 @@ public void ConfigureServices(IHostApplicationBuilder builder)
// Register password expiry service
services.AddScoped();
- // Register session service
+ // Register session service and background cleanup
services.AddScoped();
+ services.AddHostedService();
// Register group role service for group-derived permissions
services.AddScoped();
diff --git a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs
index 888d251e57..2fa5ff12d3 100644
--- a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs
@@ -1,11 +1,12 @@
using FSH.Framework.Core.Context;
using FSH.Framework.Core.Exceptions;
using FSH.Framework.Shared.Identity.Claims;
+using FSH.Modules.Identity.Contracts.Services;
using System.Security.Claims;
namespace FSH.Modules.Identity.Services;
-public class CurrentUserService : ICurrentUser, ICurrentUserInitializer
+internal sealed class CurrentUserService : ICurrentUserService
{
private ClaimsPrincipal? _user;
diff --git a/src/Modules/Identity/Modules.Identity/Services/DeviceTypeClassifier.cs b/src/Modules/Identity/Modules.Identity/Services/DeviceTypeClassifier.cs
new file mode 100644
index 0000000000..e2f92adff7
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity/Services/DeviceTypeClassifier.cs
@@ -0,0 +1,42 @@
+namespace FSH.Modules.Identity.Services;
+
+///
+/// Classifies device types based on user agent device family strings.
+/// Extracted from SessionService to reduce cyclomatic complexity.
+///
+public static class DeviceTypeClassifier
+{
+ private const string Desktop = "Desktop";
+ private const string Mobile = "Mobile";
+ private const string Tablet = "Tablet";
+
+ private static readonly string[] MobileKeywords = ["mobile", "phone", "iphone", "android"];
+ private static readonly string[] TabletKeywords = ["tablet", "ipad"];
+
+ ///
+ /// Determines the device type from a user agent device family string.
+ ///
+ /// The device family string from user agent parsing.
+ /// Device type: "Desktop", "Mobile", or "Tablet".
+ public static string Classify(string? deviceFamily)
+ {
+ if (string.IsNullOrWhiteSpace(deviceFamily) || deviceFamily == "Other")
+ {
+ return Desktop;
+ }
+
+ var normalized = deviceFamily.ToLowerInvariant();
+
+ if (MobileKeywords.Any(keyword => normalized.Contains(keyword, StringComparison.Ordinal)))
+ {
+ return Mobile;
+ }
+
+ if (TabletKeywords.Any(keyword => normalized.Contains(keyword, StringComparison.Ordinal)))
+ {
+ return Tablet;
+ }
+
+ return Desktop;
+ }
+}
diff --git a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs
index 8184f2b112..a59b399660 100644
--- a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs
@@ -1,5 +1,6 @@
using FSH.Framework.Core.Context;
using FSH.Framework.Web.Origin;
+using FSH.Modules.Identity.Contracts.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
@@ -9,7 +10,7 @@ namespace FSH.Modules.Identity.Services;
/// Provides HTTP request context information through an abstraction.
/// This allows handlers to access request metadata without direct ASP.NET Core dependencies.
///
-public sealed class RequestContextService : IRequestContext
+internal sealed class RequestContextService : IRequestContextService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Uri? _originUrl;
diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs
new file mode 100644
index 0000000000..8f6348b216
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs
@@ -0,0 +1,70 @@
+using FSH.Modules.Identity.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace FSH.Modules.Identity.Services;
+
+///
+/// Background service that periodically cleans up expired sessions.
+/// Runs every hour and removes sessions that have been expired for more than 30 days.
+///
+public sealed class SessionCleanupHostedService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+ private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
+ private readonly int _retentionDays = 30;
+
+ public SessionCleanupHostedService(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Session cleanup service started");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_cleanupInterval, stoppingToken);
+ await CleanupExpiredSessionsAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ // Expected during shutdown
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error during session cleanup");
+ }
+ }
+
+ _logger.LogInformation("Session cleanup service stopped");
+ }
+
+ private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);
+ var expiredSessions = await db.UserSessions
+ .Where(s => s.ExpiresAt < DateTime.UtcNow && s.ExpiresAt < cutoffDate)
+ .ToListAsync(cancellationToken);
+
+ if (expiredSessions.Count > 0)
+ {
+ db.UserSessions.RemoveRange(expiredSessions);
+ await db.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count);
+ }
+ }
+}
diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs
index a116c6b68b..a16dd9baaa 100644
--- a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs
@@ -58,7 +58,7 @@ public async Task CreateSessionAsync(
ipAddress: ipAddress,
userAgent: userAgent,
expiresAt: expiresAt,
- deviceType: GetDeviceType(clientInfo.Device.Family),
+ deviceType: DeviceTypeClassifier.Classify(clientInfo.Device.Family),
browser: clientInfo.UA.Family,
browserVersion: clientInfo.UA.Major,
operatingSystem: clientInfo.OS.Family,
@@ -328,30 +328,6 @@ public async Task CleanupExpiredSessionsAsync(
}
}
- private static string GetDeviceType(string deviceFamily)
- {
- if (string.IsNullOrWhiteSpace(deviceFamily) || deviceFamily == "Other")
- {
- return "Desktop";
- }
-
- if (deviceFamily.Contains("mobile", StringComparison.OrdinalIgnoreCase) ||
- deviceFamily.Contains("phone", StringComparison.OrdinalIgnoreCase) ||
- deviceFamily.Contains("iphone", StringComparison.OrdinalIgnoreCase) ||
- deviceFamily.Contains("android", StringComparison.OrdinalIgnoreCase))
- {
- return "Mobile";
- }
-
- if (deviceFamily.Contains("tablet", StringComparison.OrdinalIgnoreCase) ||
- deviceFamily.Contains("ipad", StringComparison.OrdinalIgnoreCase))
- {
- return "Tablet";
- }
-
- return "Desktop";
- }
-
private static UserSessionDto MapToDto(UserSession session, bool isCurrentSession)
{
return new UserSessionDto