fix(expense): resolve current user id from 'sub' JWT claim

Live verification revealed the JWT carries the user id in the 'sub' claim
(NameClaimType=sub, MapInboundClaims=false), so ClaimTypes.NameIdentifier is
null at runtime. This caused ExpensesController.GetMine/GetById to throw
NullReferenceException (500) on the '!.Value', and made the services fall back
to 'system' — silently defeating the self-ownership guard. Resolve via
NameIdentifier (unit tests) then 'sub' (real tokens). Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-29 19:08:21 -07:00
parent 95008788f3
commit e1f99158aa
4 changed files with 45 additions and 6 deletions
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Expense;
@@ -16,6 +17,10 @@ public class ExpensesController : ControllerBase
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin");
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
private string CurrentUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "";
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null,
@@ -29,8 +34,7 @@ public class ExpensesController : ControllerBase
[HttpGet("mine")]
public async Task<IActionResult> GetMine([FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var uid = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value;
return Ok(await _svc.GetMineAsync(uid, status, page, pageSize));
return Ok(await _svc.GetMineAsync(CurrentUserId(), status, page, pageSize));
}
[HttpGet("{id:int}")]
@@ -38,8 +42,7 @@ public class ExpensesController : ControllerBase
{
var dto = await _svc.GetByIdAsync(id);
if (dto is null) return NotFound();
var uid = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value;
if (!CanViewAll() && dto.SubmittedBy != uid) return Forbid();
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
return Ok(dto);
}