Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a8adb96
chore(dotnet-tools.json): bump dotnet-ef cli tool
Aleks334 Oct 22, 2026
bdd4b67
feat: generate access and refresh token during user provisioning
Aleks334 Oct 22, 2026
1688fcc
refactor: update infrastructure config for jwt settings
Aleks334 Oct 22, 2026
3b43b90
feat(AppDbContext): configure index on RefreshToken entity
Aleks334 Oct 22, 2026
9944a37
refactor: store in config tokens lifetime
Aleks334 Oct 22, 2026
45f67d5
refactor: use DateTimeOffset to prevent DST shift issues
Aleks334 Oct 22, 2026
4229629
refactor: wrap token pair in custom record
Aleks334 Oct 22, 2026
d69da73
test(ProvisionUserCommandHandlerTests): check for tokens generation
Aleks334 Oct 22, 2026
28e75c5
refactor(PresentationConfiguration.cs): inject IOptions to use typesa…
Aleks334 Oct 22, 2026
cd08f94
feat(RefreshToken): configure new entity in ef core
Aleks334 Oct 22, 2026
2a8d619
feat: add new migration
Aleks334 Oct 22, 2026
4ae36d8
refactor: rename public api dto to Response suffix
Aleks334 Oct 22, 2026
476fb91
refactor: change naming convention for unit tests to Test suffix
Aleks334 Oct 22, 2026
65ba135
refactor: add stricter rules for keys in validators
Aleks334 Oct 22, 2026
96c4882
test: add unit tests for JwtUserSessionService
Aleks334 Oct 22, 2026
2bea6c3
feat: add accessor to user claims from jwt in api request
Aleks334 Oct 24, 2026
a50d37a
refactor(AuthController): set jwt pair in cookies instead of bearer h…
Aleks334 Oct 24, 2026
4cd4994
refactor: extract constants for cookies name
Aleks334 Oct 24, 2026
f70b80c
feat: add endpoint for refreshing session
Aleks334 Oct 24, 2026
90ffb00
refactor(JwtUserSessionServiceTest): replace dbcontext mocking with i…
Aleks334 Oct 24, 2026
a27e564
docs: update README.md
Aleks334 Oct 24, 2026
5c986e3
test(JwtUserSessionServiceTest): cover logic in RefreshSession method
Aleks334 Oct 24, 2026
9a584b1
refactor(AppDbContext): load automatically all entity type configs
Aleks334 Nov 2, 2026
88d03a1
refactor: define 1:n relationship between user and refresh token
Aleks334 Nov 2, 2026
f2a137c
feat(JwtUserSessionService): revoke all refresh tokens of user in cas…
Aleks334 Nov 6, 2026
27f0b8a
test(JwtUserSessionServiceTest): cover new logic & add fixture
Aleks334 Nov 6, 2026
8941aa0
fix(JwtUserSessionService): possible concurrency issues during refres…
Aleks334 Nov 12, 2026
ad6387c
feat: hash refresh tokens before saving in db
Aleks334 Nov 12, 2026
8172bda
test(JwtUserSessionServiceTest): update test to include hashing and n…
Aleks334 Nov 12, 2026
09d4885
fix: update rebased changes to match new code
Aleks334 Nov 12, 2026
4c43b4f
feat: add new migration
Aleks334 Nov 12, 2026
6fa8c80
fix: formatting
Aleks334 Nov 12, 2026
a8477ea
fix(AuthController): use POST for /refresh
Aleks334 Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion server/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ dotnet_diagnostic.S3400.severity = silent
dotnet_diagnostic.S1135.severity = suggestion

# --- Naming convention rules for unit tests ---
[**/*Tests.cs]
[**/*Test.cs]
dotnet_diagnostic.CA1707.severity = none

# E.g. <Method>_Should<ExpectedBehavior>_When<Condition>
Expand Down
1 change: 1 addition & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ UsosOAuth__ConsumerKey=your-usos-consumer-key
UsosOAuth__ConsumerSecret=your-usos-consumer-secret
UsosOAuth__HashingKey=your-64-bytes-length-hashing-key-for-user-usos-id
ConnectionStrings__DefaultConnection=Host=localhost;Port=5433;Database=putwiki;Username=postgres;Password=password
Jwt__SecretKey=your-32-bytes-length-secret-key-for-signing-jwt-tokens
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Threading;
using System.Threading.Tasks;

namespace Application.Interfaces;
namespace Application.Abstractions;

public interface IUnitOfWork
{
Expand Down
9 changes: 9 additions & 0 deletions server/Application/Abstractions/IUserContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System;

namespace Application.Abstractions;

public interface IUserContext
{
bool IsAuthenticated { get; }
Guid UserId { get; }
}
10 changes: 10 additions & 0 deletions server/Application/Auth/IUserSessionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System;
using System.Threading.Tasks;

namespace Application.Auth;

public interface IUserSessionService
{
JwtTokenPair CreateSession(Guid userId);
Task<JwtTokenPair?> RefreshSession(string rawRefreshTokenString);
}
3 changes: 3 additions & 0 deletions server/Application/Auth/JwtTokenPair.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Application.Auth;

public record JwtTokenPair(string AccessToken, string RefreshToken);
5 changes: 4 additions & 1 deletion server/Application/Extensions/ApplicationConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Application.Features.AcademicTeachers.Queries;
using Application.Features.AcademicTeachers.Queries;
using Application.Features.Users.Commands.ProvisionUser;
using Application.Features.Users.Commands.RefreshUserSession;
using Application.Features.Users.Commands.UpdateNickname;
using Application.Mappings;

Expand All @@ -17,6 +18,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services
});

services.AddScoped<ProvisionUserCommandHandler>();
services.AddScoped<RefreshSessionCommandHandler>();

services.AddScoped<GetAcademicTeacherQueryHandler>();
services.AddScoped<UpdateNicknameCommandHandler>();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace Application.Features.AcademicTeachers.Queries;

public record AcademicTeacherDto(
public record AcademicTeacherResponse(
string Id,
string Slug,
AcademicTeacherBaseInfoDto BaseInfo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Application.Features.AcademicTeachers.Queries;

public class GetAcademicTeacherQueryHandler(IAcademicTeacherQueryService queryService)
{
public async Task<Result<AcademicTeacherDto>> ExecuteAsync(GetAcademicTeacherQuery query,
public async Task<Result<AcademicTeacherResponse>> ExecuteAsync(GetAcademicTeacherQuery query,
CancellationToken ct = default)
{
var teacherDto = await queryService.GetAcademicTeacherByIdAsync(query.Id, ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ namespace Application.Features.AcademicTeachers.Queries;

public interface IAcademicTeacherQueryService
{
Task<AcademicTeacherDto?> GetAcademicTeacherByIdAsync(Guid id, CancellationToken ct = default);
Task<AcademicTeacherResponse?> GetAcademicTeacherByIdAsync(Guid id, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@
using System.Threading;
using System.Threading.Tasks;

using Application.Abstractions;
using Application.Auth;
using Application.Errors;
using Application.Interfaces;

using AutoMapper;

using Domain.Users;

Expand All @@ -22,11 +20,11 @@ public partial class ProvisionUserCommandHandler(
IUserRepository userRepository,
IUnitOfWork unitOfWork,
ILogger<ProvisionUserCommandHandler> logger,
IMapper mapper,
TimeProvider timeProvider)
TimeProvider timeProvider,
IUserSessionService userSessionService)
{

public async Task<Result<UserDto>> ExecuteAsync(ProvisionUserCommand cmd,
public async Task<Result<JwtTokenPair>> ExecuteAsync(ProvisionUserCommand cmd,
CancellationToken ct = default)
{
var usosResult = await usosOAuthService.HandleCallbackAndGetUserAsync(cmd.OauthToken, cmd.OauthVerifier, ct);
Expand All @@ -44,24 +42,31 @@ public async Task<Result<UserDto>> ExecuteAsync(ProvisionUserCommand cmd,
var rawUsosId = usosResult.Value.Id;
var hashedId = hasher.Hash(rawUsosId);

var existingUser = await userRepository.GetByHashedUsosIdAsync(hashedId, ct);
if (existingUser != null)
var user = await userRepository.GetByHashedUsosIdAsync(hashedId, ct);
if (user is null)
{
user = User.Create(hashedId, timeProvider.GetUtcNow());
userRepository.Add(user);
LogProvisionedNewUser(user.Id);
}
else
{
return Result.Ok(mapper.Map<UserDto>(existingUser));
LogUserLoggedIn(user.Id);
}

var newUser = User.Create(hashedId, timeProvider.GetUtcNow());
userRepository.Add(newUser);
await unitOfWork.SaveChangesAsync(ct);
var tokens = userSessionService.CreateSession(user.Id);

LogProvisionedNewAnonymousUserId(newUser.Id);
await unitOfWork.SaveChangesAsync(ct);

return Result.Ok(mapper.Map<UserDto>(newUser));
return Result.Ok(new JwtTokenPair(tokens.AccessToken, tokens.RefreshToken));
}

[LoggerMessage(LogLevel.Warning, "Provisioning aborted: USOS authentication failed. Error: {error}")]
partial void LogProvisioningAbortedUsosAuthenticationFailed(string error);

[LoggerMessage(LogLevel.Information, "Provisioned new anonymous user {userId}")]
partial void LogProvisionedNewAnonymousUserId(Guid userId);
[LoggerMessage(LogLevel.Information, "Provisioned new user with id: {userId}")]
partial void LogProvisionedNewUser(Guid userId);

[LoggerMessage(LogLevel.Information, "User with id: {userId} has logged in")]
partial void LogUserLoggedIn(Guid userId);
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Application.Features.Users.Commands.RefreshUserSession;

public record RefreshSessionCommand(string RefreshToken);
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Threading;
using System.Threading.Tasks;

using Application.Abstractions;
using Application.Auth;
using Application.Errors;

using FluentResults;

namespace Application.Features.Users.Commands.RefreshUserSession;

public class RefreshSessionCommandHandler(
IUserSessionService userSessionService,
IUnitOfWork unitOfWork)
{
public async Task<Result<JwtTokenPair>> ExecuteAsync(RefreshSessionCommand cmd, CancellationToken ct)
{
var newPair = await userSessionService.RefreshSession(cmd.RefreshToken);
if (newPair is null)
{
return Result.Fail(new UnauthorizedError("Cannot refresh user's session"));
}

await unitOfWork.SaveChangesAsync(ct);

return Result.Ok(newPair);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
using System.Threading;
using System.Threading.Tasks;

using Application.Abstractions;
using Application.Errors;
using Application.Interfaces;

using Domain.Users;

Expand Down
5 changes: 1 addition & 4 deletions server/Application/Mappings/MappingsProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,5 @@ namespace Application.Mappings;

public class MappingsProfile : Profile
{
public MappingsProfile()
{
CreateMap<User, UserDto>();
}
public MappingsProfile() { }
}
12 changes: 6 additions & 6 deletions server/Application/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@
},
"FluentValidation.DependencyInjectionExtensions": {
"type": "Direct",
"requested": "[12.1.1, )",
"resolved": "12.1.1",
"contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==",
"requested": "[12.2.0, )",
"resolved": "12.2.0",
"contentHash": "QpKXgiO9E6wXCh19X2Musn8MZM0xt7KQt/QrD6GDBn3MCUfy8a+rkhCzrYJNCfoxTsTRQ7bpKP8T3geTnJNNeQ==",
"dependencies": {
"FluentValidation": "12.1.1",
"FluentValidation": "12.2.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0"
}
},
Expand Down Expand Up @@ -57,8 +57,8 @@
},
"FluentValidation": {
"type": "Transitive",
"resolved": "12.1.1",
"contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw=="
"resolved": "12.2.0",
"contentHash": "DR3lFbdnKucaaj1/8zMNOACMz/64igzox1yVZSFVsLxYB5mxAB+wFP1WZ8fjwrZnbU1KA1DoMzHKYKxUfuZ4wQ=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
Expand Down
11 changes: 7 additions & 4 deletions server/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@
<PackageVersion Include="FluentAssertions" Version="8.11.0" />
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="FluentResults.Extensions.FluentAssertions" Version="2.2.1" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />

<!-- NOTE: pins ef core to the same version in entire solution -->
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.11.0" />
<PackageVersion Include="Moq" Version="4.21.0" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
Expand Down
13 changes: 6 additions & 7 deletions server/Infrastructure/AppDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
using Application.Interfaces;
using Application.Abstractions;

using Domain.AcademicTeachers;
using Domain.Users;

using Infrastructure.EntitiesConfiguration;
using Infrastructure.Auth;

using Microsoft.EntityFrameworkCore;

namespace Infrastructure;

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options), IUnitOfWork
{
public required DbSet<User> Users { get; set; }
public required DbSet<AcademicTeacher> AcademicTeachers { get; set; }
public DbSet<User> Users => Set<User>();
public DbSet<AcademicTeacher> AcademicTeachers => Set<AcademicTeacher>();
public virtual DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

modelBuilder.HasPostgresExtension("citext");

new UserEntityTypeConfiguration().Configure(modelBuilder.Entity<User>());
new AcademicTeacherEntityTypeConfiguration().Configure(modelBuilder.Entity<AcademicTeacher>());
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
14 changes: 14 additions & 0 deletions server/Infrastructure/Auth/Configuration/JwtSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;

namespace Infrastructure.Auth.Configuration;

public class JwtSettings
{
public const string SectionName = "Jwt";

public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromMinutes(30);
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(7);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using FluentValidation;

namespace Infrastructure.Auth.Configuration;

public class JwtTokensSettingsValidator : AbstractValidator<JwtSettings>
{
public JwtTokensSettingsValidator()
{
RuleFor(x => x.Issuer)
.NotEmpty()
.WithMessage("Missing issuer name.");

RuleFor(x => x.Audience)
.NotEmpty()
.WithMessage("Missing audience name.");

RuleFor(x => x.SecretKey)
.NotEmpty()
.WithMessage("Missing jwt signing key.");

RuleFor(x => x.SecretKey)
.MinimumLength(32)
.Matches("[a-zA-Z0-9]")
.WithMessage("Jwt signing key should be at least 32 ASCII characters (bytes) long.");
}
}
2 changes: 2 additions & 0 deletions server/Infrastructure/Auth/Configuration/UsosOAuthSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ namespace Infrastructure.Auth.Configuration;

public class UsosOAuthSettings
{
public const string SectionName = "UsosOAuth";

public string ConsumerKey { get; set; } = string.Empty;
public string ConsumerSecret { get; set; } = string.Empty;
public string BaseUrl { get; set; } = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,10 @@ public UsosOAuthSettingsValidator()
RuleFor(x => x.HashingKey)
.NotEmpty()
.WithMessage("Missing user's usos id hashing key.");

RuleFor(x => x.HashingKey)
.Length(64)
.Matches("[a-zA-Z0-9]")
.WithMessage("Hashing key should be exactly 64 ASCII characters (bytes) long.");
}
}
Loading
Loading