fix(auth): fix SQLite DateTimeOffset comparison crash in trusted-device checks

The sessions unit's live smoke test caught the same bug it fixed in
SessionsController also present in TrustedDeviceService and
TwoFactorController's device list: SQLite/Pomelo's EF Core provider
cannot translate DateTimeOffset relational comparisons or ORDER BY to
SQL, so IsDeviceTrustedAsync (the check that skips 2FA for a trusted
browser) and ListTrustedDevices would 500 on real SQLite despite
passing on EF's InMemory test provider. Same fix: equality-only in
the DB query, expiry comparison and sort after materializing.
This commit is contained in:
cesnimda
2026-07-13 01:49:25 +02:00
parent c6918cbeea
commit fb04088d62
2 changed files with 8 additions and 4 deletions
@@ -222,13 +222,14 @@ public sealed class TwoFactorController : ControllerBase
if (user is null) return Unauthorized();
var currentHash = TrustedDeviceService.CurrentDeviceTokenHash(Request);
// SQLite/Pomelo cannot translate DateTimeOffset ORDER BY to SQL (same issue as the
// expiry check in TrustedDeviceService), so sort after materializing.
var devices = await _db.TrustedDevices
.Where(x => x.UserId == user.Id)
.OrderByDescending(x => x.LastSeenAtUtc)
.Select(x => new TrustedDeviceDto(x.Id, x.DeviceLabel, x.CreatedAtUtc, x.LastSeenAtUtc, x.ExpiresAtUtc, currentHash != null && x.TokenHash == currentHash))
.ToListAsync(cancellationToken);
return Ok(devices);
return Ok(devices.OrderByDescending(x => x.LastSeenAtUtc).ToList());
}
[HttpDelete("trusted-devices/{id:int}")]
@@ -26,10 +26,13 @@ public static class TrustedDeviceService
var hash = HashToken(token);
var now = DateTimeOffset.UtcNow;
// SQLite/Pomelo cannot translate DateTimeOffset relational comparisons (>) to SQL, so the
// expiry check has to happen after materializing the row -- fine here since the equality
// filters (UserId, TokenHash) already narrow this to at most one row.
var match = await db.TrustedDevices
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.UserId == userId && x.TokenHash == hash && x.ExpiresAtUtc > now, cancellationToken);
if (match is null) return false;
.FirstOrDefaultAsync(x => x.UserId == userId && x.TokenHash == hash, cancellationToken);
if (match is null || match.ExpiresAtUtc <= now) return false;
match.LastSeenAtUtc = now;
await db.SaveChangesAsync(cancellationToken);