diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index d797617..49994c8 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -32,16 +32,16 @@ namespace JobTrackerApi.Data base.OnModelCreating(modelBuilder); modelBuilder.Entity() - .HasQueryFilter(c => CurrentUserId == null || c.OwnerUserId == CurrentUserId); + .HasQueryFilter(c => CurrentUserId != null && c.OwnerUserId == CurrentUserId); modelBuilder.Entity() - .HasQueryFilter(j => CurrentUserId == null || j.OwnerUserId == CurrentUserId); + .HasQueryFilter(j => CurrentUserId != null && j.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasKey(x => x.OwnerUserId); modelBuilder.Entity() - .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasData(new RuleSettings { Id = 1 }); @@ -65,10 +65,10 @@ namespace JobTrackerApi.Data .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() - .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() - .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Ignore(); @@ -92,13 +92,13 @@ namespace JobTrackerApi.Data .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() - .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.UploadedAtUtc }); modelBuilder.Entity() - .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.StartedAtUtc }); @@ -110,7 +110,7 @@ namespace JobTrackerApi.Data .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() - .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId }) diff --git a/JobTrackerApi.Tests/LocalAuthIdentityTests.cs b/JobTrackerApi.Tests/LocalAuthIdentityTests.cs new file mode 100644 index 0000000..ee4aebd --- /dev/null +++ b/JobTrackerApi.Tests/LocalAuthIdentityTests.cs @@ -0,0 +1,52 @@ +using System.Security.Claims; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class LocalAuthIdentityTests +{ + [Fact] + public void GetRequiredUserId_returns_null_when_subject_claim_is_missing() + { + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Email, "ghost@example.com") + }, "local")); + + var userId = LocalAuthIdentity.GetRequiredUserId(principal); + + Assert.Null(userId); + } + + [Fact] + public void GetRequiredUserId_returns_nameidentifier_when_present() + { + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "user-123") + }, "local")); + + var userId = LocalAuthIdentity.GetRequiredUserId(principal); + + Assert.Equal("user-123", userId); + } + + [Fact] + public async Task Owner_scoped_query_filters_fail_closed_when_current_user_is_missing() + { + await using var db = TestHostFactory.CreateInMemoryDb(null); + db.Companies.Add(new Company { Name = "Secret Co", OwnerUserId = "user-1" }); + db.JobApplications.Add(new JobApplication { JobTitle = "Secret Job", Status = "Applied", OwnerUserId = "user-1" }); + db.UserRuleSettings.Add(new UserRuleSettings { OwnerUserId = "user-1", AppliedFollowUpDays = 5 }); + await db.SaveChangesAsync(); + + Assert.Empty(await db.Companies.ToListAsync()); + Assert.Empty(await db.JobApplications.ToListAsync()); + Assert.Empty(await db.UserRuleSettings.ToListAsync()); + } +} diff --git a/JobTrackerApi.Tests/TestSupport/TestHostFactory.cs b/JobTrackerApi.Tests/TestSupport/TestHostFactory.cs index 26f9acd..8e663ef 100644 --- a/JobTrackerApi.Tests/TestSupport/TestHostFactory.cs +++ b/JobTrackerApi.Tests/TestSupport/TestHostFactory.cs @@ -13,7 +13,7 @@ public static class TestHostFactory { // Keep the EF-backed controller tests on the same minimal setup so they fail for product // reasons, not because each file drifted into a slightly different fake host configuration. - public static JobTrackerContext CreateInMemoryDb(string userId = "user-1") + public static JobTrackerContext CreateInMemoryDb(string? userId = "user-1") { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 48afbd6..852e9bb 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -253,6 +253,17 @@ builder.Services.AddAuthentication(options => context.Token = cookieToken; } + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal); + if (userId is not null) + { + return Task.CompletedTask; + } + + context.Fail("Local tokens must include a subject/nameidentifier claim."); return Task.CompletedTask; } }; diff --git a/JobTrackerApi/Services/CurrentUserService.cs b/JobTrackerApi/Services/CurrentUserService.cs index 03e2432..bc5e165 100644 --- a/JobTrackerApi/Services/CurrentUserService.cs +++ b/JobTrackerApi/Services/CurrentUserService.cs @@ -16,14 +16,5 @@ public sealed class CurrentUserService : ICurrentUserService _http = http; } - public string? UserId - { - get - { - var u = _http.HttpContext?.User; - if (u is null) return null; - if (u.Identity?.IsAuthenticated != true) return null; - return u.FindFirstValue(ClaimTypes.NameIdentifier) ?? u.FindFirstValue("sub"); - } - } + public string? UserId => LocalAuthIdentity.GetRequiredUserId(_http.HttpContext?.User); } diff --git a/JobTrackerApi/Services/LocalAuthIdentity.cs b/JobTrackerApi/Services/LocalAuthIdentity.cs new file mode 100644 index 0000000..7376a1a --- /dev/null +++ b/JobTrackerApi/Services/LocalAuthIdentity.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; + +namespace JobTrackerApi.Services; + +public static class LocalAuthIdentity +{ + public static string? GetRequiredUserId(ClaimsPrincipal? user) + { + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub"); + return string.IsNullOrWhiteSpace(userId) ? null : userId; + } +}