Compare commits

...
30 changed files with 928 additions and 68 deletions
+1 -2
View File
@@ -4,7 +4,6 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -13,4 +12,4 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
</ItemGroup>
</Project>
</Project>
-1
View File
@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,34 @@
using FiscalOS.Core.Identity;
namespace FiscalOS.API.Http;
internal static class HttpContextExtensions
{
private const string RefreshTokenCookieName = "fiscalos_refresh_cookie";
public static Guid GetUserId(this HttpContext context)
{
var id = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty;
return Guid.TryParse(id, out var userId) ? userId : Guid.Empty;
}
public static void SetRefreshTokenCookie(this HttpContext context, RefreshToken token)
{
context.Response.Cookies.Append(
RefreshTokenCookieName,
token.Token,
new CookieOptions
{
HttpOnly = true,
Expires = token.ExpiresAt,
SameSite = SameSiteMode.Strict,
Secure = true
}
);
}
public static string GetRefreshTokenFromCookie(this HttpContext context)
{
return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty;
}
}
+4 -13
View File
@@ -32,22 +32,13 @@ internal static class Endpoint
}
var accessToken = tokenService.GenerateAccessToken(user);
var refreshToken = tokenService.GenerateRefreshToken(user);
await appDbContext.RefreshTokens.AddAsync(refreshToken);
user.AddRefreshToken(refreshToken);
await appDbContext.SaveChangesAsync();
httpContext.Response.Cookies.Append(
"fiscalos_refresh_cookie",
refreshToken.Token,
new CookieOptions
{
HttpOnly = true,
Expires = refreshToken.ExpiresAt,
SameSite = SameSiteMode.Strict,
Secure = true
}
);
httpContext.SetRefreshTokenCookie(refreshToken);
return Results.Ok(Response.From(accessToken));
}
+29 -6
View File
@@ -1,19 +1,42 @@
using FiscalOS.Infra.Authentication;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
builder.Services.AddInfrastructure();
builder.Services.AddAuthentication(static o =>
{
o.DefaultAuthenticateScheme = Schemes.Default;
o.DefaultChallengeScheme = Schemes.Default;
})
.AddJwtBearer(Schemes.Default)
.AddJwtBearer(Schemes.AllowExpiredTokens);
builder.Services.AddAuthorizationBuilder()
.AddPolicy(Schemes.Default, static policy =>
{
policy.AuthenticationSchemes = [Schemes.Default];
policy.RequireAuthenticatedUser();
})
.AddPolicy(Schemes.AllowExpiredTokens, static policy =>
{
policy.AuthenticationSchemes = [Schemes.AllowExpiredTokens];
policy.RequireAuthenticatedUser();
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseStatusCodePages();
app.MapLoginEndpoint();
app.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens);
app.Run();
+60
View File
@@ -0,0 +1,60 @@
namespace FiscalOS.API.Refresh;
internal static class Endpoint
{
private const string Route = "/refresh";
public static RouteHandlerBuilder MapRefreshEndpoint(this WebApplication app)
{
return app.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromServices] AppDbContext appDbContext,
[FromServices] TimeProvider timeProvider,
[FromServices] ITokenGenerator tokenGenerator
)
{
var requestUserId = httpContext.GetUserId();
var token = httpContext.GetRefreshTokenFromCookie();
var refreshToken = await appDbContext.RefreshTokens
.Include(t => t.User)
.SingleOrDefaultAsync(t => t.Token == token);
if (refreshToken is null || refreshToken.User is null)
{
return Results.BadRequest();
}
if (refreshToken.UserId != requestUserId)
{
await appDbContext.RefreshTokens
.Where(t => t.UserId == refreshToken.UserId)
.ExecuteUpdateAsync(t => t.SetProperty(t => t.Revoked, true));
await appDbContext.SaveChangesAsync();
return Results.Forbid();
}
var now = timeProvider.GetUtcNow();
if (refreshToken.Revoked || refreshToken.IsExpired(now))
{
return Results.BadRequest();
}
var accessToken = tokenGenerator.GenerateAccessToken(refreshToken.User);
var newRefreshToken = tokenGenerator.GenerateRefreshToken(refreshToken.User);
refreshToken.Revoke();
await appDbContext.RefreshTokens.AddAsync(newRefreshToken);
await appDbContext.SaveChangesAsync();
httpContext.SetRefreshTokenCookie(newRefreshToken);
return Results.Ok(Response.From(accessToken));
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace FiscalOS.API.Refresh;
internal sealed record Response
{
public string AccessToken { get; init; }
[JsonConstructor]
private Response()
{
AccessToken = string.Empty;
}
private Response(string accessToken)
{
AccessToken = accessToken;
}
public static Response From(string accessToken)
{
return new(accessToken);
}
}
+4 -1
View File
@@ -1,10 +1,13 @@
global using System.ComponentModel.DataAnnotations;
global using System.Security.Claims;
global using System.Text.Json.Serialization;
global using FiscalOS.API.Http;
global using FiscalOS.API.Login;
global using FiscalOS.API.Refresh;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.EntityFrameworkCore;
+25 -1
View File
@@ -5,7 +5,8 @@ public sealed class RefreshToken : Entity
public string Token { get; init; } = string.Empty;
public DateTimeOffset ExpiresAt { get; init; }
public Guid UserId { get; init; }
public User User { get; init; } = User.New();
public User? User { get; init; }
public bool Revoked { get; set; }
private RefreshToken()
{
@@ -20,4 +21,27 @@ public sealed class RefreshToken : Entity
ExpiresAt = expiresAt,
};
}
public static RefreshToken From(User user, string token, DateTimeOffset expiresAt)
{
ArgumentNullException.ThrowIfNull(user);
return new()
{
User = user,
UserId = user.Id,
Token = token,
ExpiresAt = expiresAt,
};
}
public void Revoke()
{
Revoked = true;
}
public bool IsExpired(DateTimeOffset now)
{
return now >= ExpiresAt;
}
}
+10 -1
View File
@@ -2,10 +2,12 @@ namespace FiscalOS.Core.Identity;
public sealed class User : Entity
{
private readonly List<RefreshToken> _refreshTokens = [];
public string Username { get; init; } = string.Empty;
public string HashedPassword { get; init; } = string.Empty;
public ICollection<RefreshToken> RefreshTokens { get; init; } = [];
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
private User()
{
@@ -24,4 +26,11 @@ public sealed class User : Entity
HashedPassword = hashedPassword,
};
}
public void AddRefreshToken(RefreshToken refreshToken)
{
ArgumentNullException.ThrowIfNull(refreshToken);
_refreshTokens.Add(refreshToken);
}
}
+1
View File
@@ -1,3 +1,4 @@
[*.cs]
dotnet_diagnostic.CA1062.severity = none
dotnet_diagnostic.CA5404.severity = none
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
namespace FiscalOS.Infra.Authentication;
public sealed record JwtOptions
@@ -6,6 +8,8 @@ public sealed record JwtOptions
public string Audience { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
public int ExpiryInMinutes { get; init; } = 5;
public SymmetricSecurityKey Key => new(Encoding.UTF8.GetBytes(Secret));
}
public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
@@ -22,4 +26,33 @@ public sealed record JwtOptionsSetup : IConfigureOptions<JwtOptions>
{
_configuration.GetSection(SectionName).Bind(options);
}
}
public sealed class JwtBearerOptionsSetup(IOptions<JwtOptions> jwtOptions) : IConfigureNamedOptions<JwtBearerOptions>
{
private readonly JwtOptions _jwtOptions = jwtOptions.Value;
public void Configure(string? name, JwtBearerOptions options)
{
options.TokenValidationParameters = new()
{
ValidIssuer = _jwtOptions.Issuer,
ValidAudience = _jwtOptions.Audience,
IssuerSigningKey = _jwtOptions.Key,
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
};
if (name is Schemes.AllowExpiredTokens)
{
options.TokenValidationParameters.ValidateLifetime = false;
}
}
public void Configure(JwtBearerOptions options)
{
Configure(Options.DefaultName, options);
}
}
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
namespace FiscalOS.Infra.Authentication;
public static class Schemes
{
public const string Default = JwtBearerDefaults.AuthenticationScheme;
public const string AllowExpiredTokens = "AllowExpiredTokens";
}
@@ -33,7 +33,6 @@ public sealed class TokenGenerator : ITokenGenerator
public string GenerateAccessToken(User user)
{
var tokenHandler = new JwtSecurityTokenHandler();
var secretKeyBytes = Encoding.UTF8.GetBytes(_jwtOptions.Value.Secret);
var issuedAt = _timeProvider.GetUtcNow();
var expiresAt = issuedAt.AddMinutes(_jwtOptions.Value.ExpiryInMinutes);
List<Claim> claims = [
@@ -49,7 +48,7 @@ public sealed class TokenGenerator : ITokenGenerator
Issuer = _jwtOptions.Value.Issuer,
Audience = _jwtOptions.Value.Audience,
SigningCredentials = new(
new SymmetricSecurityKey(secretKeyBytes),
_jwtOptions.Value.Key,
SecurityAlgorithms.HmacSha256Signature
),
};
@@ -0,0 +1,41 @@
namespace FiscalOS.Infra.Authorization;
internal sealed class ProblemDetailsAuthResultHandler : IAuthorizationMiddlewareResultHandler
{
public Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult
)
{
if (authorizeResult.Succeeded)
{
return next(context);
}
context.Response.StatusCode = authorizeResult.Forbidden
? StatusCodes.Status403Forbidden
: StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/problem+json";
var problemDetails = authorizeResult.Forbidden
? new ProblemDetails
{
Title = "Forbidden",
Status = StatusCodes.Status403Forbidden,
Detail = "You do not have permission to access this resource."
}
: new ProblemDetails
{
Title = "Unauthorized",
Status = StatusCodes.Status401Unauthorized,
Detail = "Authentication is required to access this resource."
};
var json = JsonSerializer.Serialize(problemDetails);
return context.Response.WriteAsync(json);
}
}
+2 -1
View File
@@ -45,7 +45,6 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
modelBuilder.Entity<User>(static eb =>
{
eb.HasMany(static u => u.RefreshTokens)
.WithOne(static t => t.User)
.HasForeignKey(static t => t.UserId)
@@ -56,10 +55,12 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
eb.Property(static u => u.HashedPassword);
});
modelBuilder.Entity<RefreshToken>(static eb =>
{
eb.Property(static t => t.Id);
eb.Property(static t => t.ExpiresAt);
eb.Property(static t => t.UserId);
eb.Property(static t => t.Token);
eb.HasIndex(static t => t.Token).IsUnique();
@@ -4,13 +4,20 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
services.AddSingleton(TimeProvider.System);
services.ConfigureOptions<JwtOptionsSetup>();
services.ConfigureOptions<JwtBearerOptionsSetup>();
services.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
services.ConfigureOptions<AppDbContextOptionsSetup>();
services.AddDbContext<AppDbContext>();
services.AddHostedService<MigrationService>();
return services;
}
}
@@ -0,0 +1,101 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260203115122_AddUserIdPropToRefreshToken")]
partial class AddUserIdPropToRefreshToken
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddUserIdPropToRefreshToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,104 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260203164938_AddRevokedPropertyToRefreshToken")]
partial class AddRevokedPropertyToRefreshToken
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddRevokedPropertyToRefreshToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Revoked",
table: "RefreshTokens",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Revoked",
table: "RefreshTokens");
}
}
}
@@ -29,6 +29,9 @@ namespace FiscalOS.Infra.Migrations
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
+6
View File
@@ -2,13 +2,19 @@ global using System.IdentityModel.Tokens.Jwt;
global using System.Security.Claims;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Authorization;
global using FiscalOS.Infra.Data;
global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Authorization.Policy;
global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.EntityFrameworkCore.Diagnostics;
global using Microsoft.Extensions.Configuration;
@@ -0,0 +1,101 @@
namespace FiscalOS.API.Tests.Assertions;
internal static class HttpResponseExtensions
{
public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance)
{
return new HttpResponseMessageAssertions(instance, AssertionChain.GetOrCreate());
}
}
internal sealed class HttpResponseMessageAssertions(
HttpResponseMessage instance,
AssertionChain assertionChain
) : ReferenceTypeAssertions<HttpResponseMessage, HttpResponseMessageAssertions>(instance, assertionChain)
{
private readonly AssertionChain _chain = assertionChain;
protected override string Identifier => "HttpResponseMessage";
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, T>> BeJsonContentOfType<T>(
HttpStatusCode expectedStatusCode
)
{
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json")
.FailWith(
"Expected response to be application/json, but found {0}",
Subject.Content.Headers.ContentType?.MediaType
);
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
.FailWith(
"Expected response status code to be {0}, but found {1}",
expectedStatusCode,
Subject.StatusCode
);
var content = await Subject.Content.ReadFromJsonAsync<T>();
_chain.ForCondition(content is not null)
.FailWith($"Expected body to be a valid {typeof(T).Name}, but it could not be deserialized.");
return new AndWhichConstraint<HttpResponseMessageAssertions, T>(this, content!);
}
public AndConstraint<HttpResponseMessageAssertions> HaveSetCookieHeader(string cookieName)
{
_chain.ForCondition(Subject.Headers.TryGetValues("Set-Cookie", out var setCookieHeaders))
.FailWith("Expected response to have 'Set-Cookie' header, but it was not found.");
var hasCookie = setCookieHeaders!.Any(header => header.StartsWith(cookieName + "=", StringComparison.OrdinalIgnoreCase));
_chain.ForCondition(hasCookie)
.FailWith($"Expected response to have 'Set-Cookie' header for cookie '{cookieName}', but it was not found.");
return new AndConstraint<HttpResponseMessageAssertions>(this);
}
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, ProblemDetails>> BeProblemDetails(HttpStatusCode expectedStatusCode)
{
var problem = await ValidateAndDeserialize<ProblemDetails>(expectedStatusCode);
return new AndWhichConstraint<HttpResponseMessageAssertions, ProblemDetails>(this, problem);
}
public async Task<AndWhichConstraint<HttpResponseMessageAssertions, ValidationProblemDetails>> BeValidationProblemDetails(
IDictionary<string, string[]> expectedErrors,
HttpStatusCode expectedStatusCode = HttpStatusCode.BadRequest
)
{
var problem = await ValidateAndDeserialize<ValidationProblemDetails>(expectedStatusCode);
_chain.ForCondition(problem.Errors is not null)
.FailWith("Expected ValidationProblemDetails to contain errors, but the Errors dictionary was null.");
problem.Errors.Should().BeEquivalentTo(expectedErrors, "the validation errors should match the expected dictionary");
return new AndWhichConstraint<HttpResponseMessageAssertions, ValidationProblemDetails>(this, problem);
}
private async Task<T> ValidateAndDeserialize<T>(HttpStatusCode expectedStatusCode) where T : ProblemDetails
{
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/problem+json")
.FailWith(
"Expected response to be application/problem+json, but found {0}",
Subject.Content.Headers.ContentType?.MediaType
);
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
.FailWith(
"Expected response status code to be {0}, but found {1}",
expectedStatusCode,
Subject.StatusCode
);
var problem = await Subject.Content.ReadFromJsonAsync<T>();
_chain.ForCondition(problem is not null)
.FailWith($"Expected body to be a valid {typeof(T).Name}, but it could not be deserialized.");
return problem!;
}
}
@@ -0,0 +1,61 @@
namespace FiscalOS.API.Tests.Infra;
internal sealed class JwtTokenBuilder
{
private const string Issuer = "TestIssuer";
private const string Audience = "TestAudience";
private const int ExpiryInMinutes = 5;
private static readonly string Secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
private readonly List<Claim> _claims = [];
private DateTimeOffset _expiresAt = DateTimeOffset.UtcNow.AddMinutes(ExpiryInMinutes);
public static JwtOptions DefaultJwtOptions => new()
{
Issuer = Issuer,
Audience = Audience,
Secret = Secret,
ExpiryInMinutes = ExpiryInMinutes,
};
public static JwtTokenBuilder New()
{
return new JwtTokenBuilder();
}
public JwtTokenBuilder WithClaim(string type, string value)
{
_claims.Add(new(type, value));
return this;
}
public JwtTokenBuilder WithExpiresAt(DateTimeOffset expiresAt)
{
_expiresAt = expiresAt;
return this;
}
public string Build()
{
var tokenHandler = new JwtSecurityTokenHandler();
var issuedAt = _expiresAt.AddMinutes(-ExpiryInMinutes);
var descriptor = new SecurityTokenDescriptor()
{
Subject = new(_claims),
NotBefore = issuedAt.UtcDateTime,
IssuedAt = issuedAt.UtcDateTime,
Expires = _expiresAt.UtcDateTime,
Issuer = Issuer,
Audience = Audience,
SigningCredentials = new(
DefaultJwtOptions.Key,
SecurityAlgorithms.HmacSha256Signature
),
};
var securityToken = tokenHandler.CreateToken(descriptor);
var jwtToken = tokenHandler.WriteToken(securityToken);
return jwtToken;
}
}
+3 -17
View File
@@ -1,7 +1,3 @@
using System.Security.Cryptography;
using FiscalOS.Infra.Authentication;
namespace FiscalOS.API.Tests.Infra;
public class TestApi : WebApplicationFactory<Program>
@@ -14,22 +10,12 @@ public class TestApi : WebApplicationFactory<Program>
builder.ConfigureTestServices(static c =>
{
var dbOpts = Options.Create(new AppDbContextOptions()
c.AddSingleton(Options.Create(new AppDbContextOptions()
{
DatabaseFilePath = $"{Guid.NewGuid()}.db",
});
}));
c.AddSingleton(dbOpts);
var jwtOpts = Options.Create(new JwtOptions()
{
Issuer = "TestIssuer",
Audience = "TestAudience",
Secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
ExpiryInMinutes = 5,
});
c.AddSingleton(jwtOpts);
c.AddSingleton(Options.Create(JwtTokenBuilder.DefaultJwtOptions));
});
}
}
@@ -2,6 +2,8 @@ namespace FiscalOS.API.Tests.Integration;
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri LoginUri = new("/login", UriKind.Relative);
[Theory]
[ClassData<LoginValidationTestCases>]
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
@@ -12,13 +14,9 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
password = tc.Password,
};
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await res.Content.ReadFromJsonAsync<ValidationProblemDetails>(TestContext.Current.CancellationToken);
problem!.Errors.Should().BeEquivalentTo(tc.ExpectedErrors);
await res.Should().BeValidationProblemDetails(tc.ExpectedErrors);
}
[Fact]
@@ -30,9 +28,9 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
password = "@Password2",
};
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
@@ -53,13 +51,13 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
password = "@Password2",
};
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtToken()
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie()
{
await ExecuteDbContextAsync(static async (context, sp) =>
{
@@ -76,18 +74,10 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
password = "@Password1",
};
var res = await Client.PostAsJsonAsync("/login", req, TestContext.Current.CancellationToken);
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await res.Content.ReadFromJsonAsync<Login.Response>(TestContext.Current.CancellationToken);
content!.AccessToken.Should().NotBeNullOrEmpty();
res.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue();
cookies.Should().NotBeNull();
res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
await res.Should().BeJsonContentOfType<Login.Response>(HttpStatusCode.OK);
}
}
@@ -0,0 +1,193 @@
namespace FiscalOS.API.Tests.Integration;
public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri RefreshUri = new("/refresh", UriKind.Relative);
[Fact]
public async Task Refresh_WhenCalledWithNoAccessToken_ItShouldReturn401WithProblemDetails()
{
var response = await Client.PostAsync(RefreshUri, null, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Refresh_WhenCalledWithNonExistentRefreshToken_ItShouldReturn400WithProblemDetails()
{
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", "fiscalos_refresh_cookie=nonexistenttoken");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails()
{
var (users, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var user1 = User.From("User1", passwordHasher.Hash("@Password1"));
var user2 = User.From("User2", passwordHasher.Hash("@Password2"));
var refreshToken1 = tokenGenerator.GenerateRefreshToken(user2);
var refreshToken2 = tokenGenerator.GenerateRefreshToken(user2);
user2.AddRefreshToken(refreshToken1);
user2.AddRefreshToken(refreshToken2);
context.Add(user1);
context.Add(user2);
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
return (new User[] { user1, user2 }, refreshToken1);
});
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, users[0].Id.ToString())
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Forbidden);
var unrevokedTokensCountForUser2 = await ExecuteDbContextAsync(
async context => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == users[1].Id && t.Revoked == false)
.CountAsync()
);
unrevokedTokensCountForUser2.Should().Be(0);
}
[Fact]
public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails()
{
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var user = User.From("User1", passwordHasher.Hash("@Password1"));
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
refreshToken.Revoke();
user.AddRefreshToken(refreshToken);
context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
return (user, refreshToken);
});
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails()
{
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
var user = User.From("User1", passwordHasher.Hash("@Password1"));
var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1));
user.AddRefreshToken(refreshToken);
context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
return (user, refreshToken);
});
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
}
[Theory]
[InlineData(5)]
[InlineData(-5)]
public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset)
{
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
var user = User.From("User1", passwordHasher.Hash("@Password1"));
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
user.AddRefreshToken(refreshToken);
context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
return (user, refreshToken);
});
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.WithExpiresAt(DateTime.UtcNow.AddMinutes(accessTokenExpiresAtOffset))
.Build();
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
await response.Should().BeJsonContentOfType<Refresh.Response>(HttpStatusCode.OK);
var oldRefreshTokenInDb = await ExecuteDbContextAsync(
async context => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true)
.SingleOrDefaultAsync()
);
oldRefreshTokenInDb.Should().NotBeNull();
var newRefreshTokenInDb = await ExecuteDbContextAsync(
async context => await context.Set<RefreshToken>()
.Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token)
.SingleOrDefaultAsync()
);
newRefreshTokenInDb.Should().NotBeNull();
}
}
+9
View File
@@ -1,9 +1,17 @@
global using System.IdentityModel.Tokens.Jwt;
global using System.Net;
global using System.Net.Http.Headers;
global using System.Net.Http.Json;
global using System.Security.Claims;
global using System.Security.Cryptography;
global using AwesomeAssertions.Execution;
global using AwesomeAssertions.Primitives;
global using FiscalOS.API.Tests.Assertions;
global using FiscalOS.API.Tests.Infra;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data;
global using Microsoft.AspNetCore.Hosting;
@@ -14,5 +22,6 @@ global using Microsoft.EntityFrameworkCore;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;
global using Microsoft.IdentityModel.Tokens;
global using Xunit.Sdk;
@@ -1,6 +1,6 @@
namespace FiscalOS.Infra.Tests.Assertions;
internal static class JwtTokenAssertionExtensions
internal static class JwtTokenAssertions
{
public static AndConstraint<string> HaveClaim(
this StringAssertions assertions,