Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace ControlService.API.Auth;

/// <summary>Reads the claims the access token carries (T5).</summary>
/// <summary>Reads the claims the access token carries.</summary>
internal static class AuthClaims
{
public static Guid? UserId(this ClaimsPrincipal principal) => GuidClaim(principal, "sub");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ private static async Task<Results<Ok<SessionResponse>, ProblemHttpResult>> Chang
}

// The refresh cookie is scoped to the refresh route, so sign-out never receives it: the session comes
// from the access token's `sid` claim instead (D3).
// from the access token's `sid` claim instead.
private static async Task<Results<NoContent, ProblemHttpResult>> SignOut(
ClaimsPrincipal principal,
ICommandHandler<SignOutCommand, Unit> handler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

namespace ControlService.API.Auth;

/// <summary>Rate limits of the authentication routes (D14, T9): a sliding window of 60 seconds
/// <summary>Rate limits of the authentication routes: a sliding window of 60 seconds
/// in 6 segments per client address, with no queue. A refused request answers like a lockout does:
/// 429 `locked_out` with `Retry-After` (AUTH-26).</summary>
internal static class AuthRateLimiting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace ControlService.API.Auth;

/// <summary>How access tokens are checked (T5, T7). Claim names stay as issued (`sub`, `sid`), because
/// <summary>How access tokens are checked. Claim names stay as issued (`sub`, `sid`), because
/// ICurrentUser reads `sub`; the lifetime is judged by the application's clock, with no skew, so 15
/// minutes means 15 minutes; and every failed authentication looks like any other error (API-12).</summary>
public sealed class ConfigureJwtBearer(IOptions<JwtOptions> jwtOptions, TimeProvider timeProvider)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace ControlService.API.Auth;

/// <summary>Issues the HS256 access token (T5). It carries the user id in `sub` (ICurrentUser reads it),
/// <summary>Issues the HS256 access token. It carries the user id in `sub` (ICurrentUser reads it),
/// the session in `sid`, and `must_change_password` only while the change is mandatory. No personal data.</summary>
public sealed class JwtAccessTokenIssuer(IOptions<JwtOptions> options, TimeProvider timeProvider) : IAccessTokenIssuer
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public sealed class JwtOptions
[Required]
public string Audience { get; init; } = string.Empty;

/// <summary>The base64 of at least 32 random bytes. A secret: user secrets locally (T5).</summary>
/// <summary>The base64 of at least 32 random bytes. A secret: user secrets locally.</summary>
public string SigningKey { get; init; } = string.Empty;

public byte[] SigningKeyBytes() => Convert.FromBase64String(SigningKey);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace ControlService.API.Auth;

/// <summary>The refresh token cookie (T6). Its `Path` is the refresh route only, so the
/// <summary>The refresh token cookie. Its `Path` is the refresh route only, so the
/// browser sends it nowhere else; expiring it needs the same name and `Path`.</summary>
internal static class RefreshCookie
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace ControlService.API.Auth;

/// <summary>The body of sign-in, refresh and change-password (D1). `ExpiresIn` is in seconds, not a
/// <summary>The body of sign-in, refresh and change-password. `ExpiresIn` is in seconds, not a
/// timestamp, so a wrong clock on the person's computer does not break the refresh decision.</summary>
public sealed record SessionResponse(string AccessToken, int ExpiresIn, bool MustChangePassword)
{
Expand Down
4 changes: 2 additions & 2 deletions backend/ControlService/src/ControlService.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();

// ICurrentUser and TimeProvider.System are registered here (T12) because AuditFieldsInterceptor
// ICurrentUser and TimeProvider.System are registered here because AuditFieldsInterceptor
// (registered by AddInfrastructure) depends on both.
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ICurrentUser, HttpCurrentUser>();
Expand All @@ -33,7 +33,7 @@
app.MapOpenApi();
app.MapScalarApiReference();

// Migrations run automatically only here; MigrateAsync also runs the seeder (D2).
// Migrations run automatically only here; MigrateAsync also runs the seeder.
using var scope = app.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.MigrateAsync();
}
Expand Down
4 changes: 2 additions & 2 deletions backend/ControlService/src/ControlService.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

var builder = DistributedApplication.CreateBuilder(args);

// Persistent containers keep running after the AppHost stops and are reused on the next start,
// which a fixed container name requires. Stop them in Docker Desktop when you are done for the day.
// A persistent container keeps running after the AppHost stops and is reused on the next start,
// which a fixed container name requires. Stop it in Docker Desktop when you are done for the day.
// The database files live in the named volume, so they survive even if the container is removed.
var postgres = builder.AddPostgres("postgres")
.WithContainerName($"{NamePrefix}-postgres")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace ControlService.Application.Auth.GetMe;

/// <summary>The signed-in person and their effective level on every screen, in catalog order,
/// including `negado` (D2). `Level` is the wire value.</summary>
/// including `negado`. `Level` is the wire value.</summary>
public sealed record MeResponse(Guid Id, string DisplayName, string Login, IReadOnlyList<ScreenLevelResponse> Levels);

public sealed record ScreenLevelResponse(string Screen, string Level);
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ public interface IAccessTokenIssuer
}

/// <summary>The signed access token and how many seconds it lasts, so a wrong clock on the person's
/// computer does not break the refresh decision (D1).</summary>
/// computer does not break the refresh decision.</summary>
public sealed record AccessToken(string Value, int ExpiresInSeconds);
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public async Task<Result<SessionGrant>> Handle(RefreshSessionCommand command, Ca
return Result<SessionGrant>.Failure(AuthErrors.SessionExpired);
}

// Refresh is anonymous (cookie only), so the per-request account check does not cover it (D10).
// Refresh is anonymous (cookie only), so the per-request account check does not cover it.
var user = await users.GetByIdAsync(rotated.UserId, cancellationToken);
if (user is null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace ControlService.Application.Auth;

/// <summary>What a sign-in, a refresh or a password change grants. The endpoint sends the first
/// three in the body and the refresh token in its cookie (D1).</summary>
/// three in the body and the refresh token in its cookie.</summary>
public sealed record SessionGrant(
string AccessToken,
int ExpiresIn,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
namespace ControlService.Application.Auth.SignOut;

/// <summary>`SessionId` comes from the `sid` claim of the access token (D3): the refresh cookie is
/// <summary>`SessionId` comes from the `sid` claim of the access token: the refresh cookie is
/// scoped to the refresh route, so sign-out never receives it.</summary>
public sealed record SignOutCommand(Guid SessionId);
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
namespace ControlService.Infrastructure.Auth;

/// <summary>Passwords, lockout and the mandatory-change flag on top of Identity's UserManager, without
/// SignInManager (T2). Lockout is checked before the password, and an attempt made while locked out is
/// not counted (D6).</summary>
/// SignInManager. Lockout is checked before the password, and an attempt made while locked out is
/// not counted.</summary>
public sealed class CredentialStore(UserManager<UserCredential> users) : ICredentialStore
{
public async Task<CredentialCheck> CheckPasswordAsync(Guid userId, string password, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public override async Task<IdentityResult> AccessFailedAsync(UserCredential user
return await UpdateAsync(user);
}

// Reaching the threshold locks the login and starts counting again (AUTH-08, D6).
// Reaching the threshold locks the login and starts counting again (AUTH-08).
await lockoutStore.SetLockoutEndDateAsync(
user, timeProvider.GetUtcNow().Add(Options.Lockout.DefaultLockoutTimeSpan), CancellationToken);
await lockoutStore.ResetAccessFailedCountAsync(user, CancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
namespace ControlService.Infrastructure.Auth;

/// <summary>Refresh tokens are 32 random bytes; only their SHA-256 is stored (AUTH-19). A session is
/// valid while now &lt; expires_at, and every use slides that time forward (T4).</summary>
/// valid while now &lt; expires_at, and every use slides that time forward.</summary>
public sealed class SessionStore(AppDbContext db, IOptions<AuthOptions> options, TimeProvider timeProvider) : ISessionStore
{
private TimeSpan IdleTime => TimeSpan.FromHours(options.Value.RefreshTokenIdleHours);
Expand Down Expand Up @@ -41,7 +41,7 @@ public async Task<SessionTokens> StartAsync(Guid userId, CancellationToken cance
var newHash = Hash(newToken);
var expiresAt = now + IdleTime;

// One UPDATE: two refreshes with the same token cannot both match the old hash (T4).
// One UPDATE: two refreshes with the same token cannot both match the old hash.
var rotated = await db.Sessions
.Where(session => session.TokenHash == oldHash && session.ExpiresAt > now)
.ExecuteUpdateAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace ControlService.Infrastructure.Auth;

/// <summary>The person's credential: password hash, lockout counter and security stamp, kept apart
/// from the user record (AUTH-19). `Id` is the user's id and `UserName` is that id too, so nothing
/// needs syncing when a login changes; Identity's e-mail and phone columns stay empty (D12).</summary>
/// needs syncing when a login changes; Identity's e-mail and phone columns stay empty.</summary>
public sealed class UserCredential : IdentityUser<Guid>
{
/// <summary>True while the account still has a temporary password (AUTH-14).</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace ControlService.Infrastructure.Auth;

/// <summary>One signed-in browser: only the SHA-256 of its refresh token is stored (AUTH-19). An
/// infrastructure row, not an aggregate, so it has no audit fields (T4).</summary>
/// infrastructure row, not an aggregate, so it has no audit fields.</summary>
public sealed class UserSession
{
public Guid Id { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ namespace ControlService.Infrastructure;

public static class DependencyInjection
{
// ICurrentUser and TimeProvider are registered by the caller (API/Program.cs, T12):
// ICurrentUser and TimeProvider are registered by the caller (API/Program.cs):
// AuditFieldsInterceptor depends on both, and the Admin's own creation depends on ICurrentUser
// answering the Admin's id outside a request (D3).
// answering the Admin's id outside a request.
public static IHostApplicationBuilder AddInfrastructure(this IHostApplicationBuilder builder)
{
builder.Services.AddSingleton<AuditFieldsInterceptor>();
Expand All @@ -28,7 +28,7 @@ public static IHostApplicationBuilder AddInfrastructure(this IHostApplicationBui
.ValidateDataAnnotations()
.ValidateOnStart();

// Message texts state these two values (D11, AUTH-28), and Application cannot read options.
// Message texts state these two values (AUTH-28), and Application cannot read options.
builder.Services.AddSingleton(serviceProvider =>
{
var auth = serviceProvider.GetRequiredService<IOptions<AuthOptions>>().Value;
Expand All @@ -45,7 +45,7 @@ public static IHostApplicationBuilder AddInfrastructure(this IHostApplicationBui
.ValidateOnStart();

// No SignInManager: it pulls in cookie authentication. The credential store uses UserManager
// for hashing and lockout only (T2).
// for hashing and lockout only.
builder.Services.AddIdentityCore<UserCredential>()
.AddEntityFrameworkStores<AppDbContext>()
.AddUserManager<CredentialUserManager>();
Expand Down Expand Up @@ -75,7 +75,7 @@ public static IHostApplicationBuilder AddInfrastructure(this IHostApplicationBui
SystemRecordsSeeder.SeedAsync(context, admin.Email, admin.InitialPassword, cancellationToken));
});

// AddNpgsqlDbContext pools contexts, which does not fit a scoped interceptor (T7): the
// AddNpgsqlDbContext pools contexts, which does not fit a scoped interceptor: the
// context above is registered by hand and only enriched with Aspire's retries, health
// checks, logging and telemetry.
builder.EnrichNpgsqlDbContext<AppDbContext>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
namespace ControlService.Infrastructure.Persistence;

/// <summary>Fills the audit fields on every save (CNV-10, CNV-19). A save during a request
/// without a signed-in user throws, so nothing is written without an author (D3 of #7).</summary>
/// without a signed-in user throws, so nothing is written without an author.</summary>
public sealed class AuditFieldsInterceptor(ICurrentUser currentUser, TimeProvider timeProvider) : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace ControlService.Infrastructure.Persistence;

/// <summary>Lets `dotnet ef migrations add` build the model without Program.cs or Aspire (T8).
/// <summary>Lets `dotnet ef migrations add` build the model without Program.cs or Aspire.
/// It has no connection string: migrations add only inspects the model, and no credential belongs
/// in source code.</summary>
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
namespace ControlService.Infrastructure.Persistence;

/// <summary>Inserts the Admin, its credential and the Gerenciador profile only when missing, so
/// restarting the application never duplicates or changes them (D2, D7: the Admin comes
/// restarting the application never duplicates or changes them (the Admin comes
/// first, since the profile's authorship references it). The credential is checked apart from the
/// Admin row, so databases created before authentication get it too (T3).</summary>
/// Admin row, so databases created before authentication get it too.</summary>
public static class SystemRecordsSeeder
{
public static void Seed(DbContext context, string adminEmail, string adminInitialPassword)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ public async Task<Result> SaveChangesAsync(CancellationToken cancellationToken)
}
}

// A conflict on a row deleted meanwhile is out of scope here (#10): GetDatabaseValuesAsync
// returns null and this throws, propagating as an unexpected failure, as the plan decided (D5).
// A conflict on a row deleted meanwhile is out of scope here: GetDatabaseValuesAsync
// returns null and this throws, propagating as an unexpected failure.
private async Task<Result> ConcurrencyConflict(DbUpdateConcurrencyException exception, CancellationToken cancellationToken)
{
var entry = exception.Entries.Single();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public async Task Startup_creates_the_admin_credential_with_a_mandatory_change()
}

[Fact]
public async Task Restarting_does_not_change_the_admin_credential() // previous plan, see decision 21 (was AUTH-13), T3
public async Task Restarting_does_not_change_the_admin_credential() // previous plan, see decision 21 (was AUTH-13)
{
await AuthTestSupport.DeleteAdminCredentialAsync(factory.Services);
using var first = factory.WithWebHostBuilder(_ => { });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public async Task Admin_with_the_initial_password_can_use_me_and_sign_out()
}

[Fact]
public async Task Change_password_returns_new_tokens_and_unlocks_other_endpoints() // AUTH-14, D3 of #7; activation time is the previous plan, see decision 21 (was USR-34)
public async Task Change_password_returns_new_tokens_and_unlocks_other_endpoints() // AUTH-14; activation time is the previous plan, see decision 21 (was USR-34)
{
await AuthTestSupport.ResetAdminAsync(factory.Services);
using var client = factory.CreateHttpsClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
namespace ControlService.Api.IntegrationTests.Auth;

/// <summary>The real API with a clock the test controls (sessions and tokens expire with it) and rate
/// limits high enough that no other test hits them (T12). The refresh cookie is `Secure`, so the
/// limits high enough that no other test hits them. The refresh cookie is `Secure`, so the
/// clients of this factory talk to `https://localhost`.</summary>
public class AuthApiFactory(PostgresContainerFixture postgres) : ApiFactory(postgres)
{
Expand Down Expand Up @@ -37,9 +37,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
});
}

// Stands for "any other endpoint" while #8 has no other authenticated route (T8, T12). It is mapped
// through MapApiV1() like a real one. A startup filter gets a plain ApplicationBuilder, so it needs its
// own routing, and authorization must run after that routing selects the endpoint.
// Stands for "any other endpoint" while the API has no authenticated route outside authentication.
// It is mapped through MapApiV1() like a real one. A startup filter gets a plain ApplicationBuilder,
// so it needs its own routing, and authorization must run after that routing selects the endpoint.
private sealed class TestOnlyEndpoints : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public sealed class MeApiTests(AuthApiFactory factory) : IClassFixture<AuthApiFa
private const string SessionEnded = "Sua sessão terminou. Entre de novo para continuar. Suas abas continuam abertas.";

[Fact]
public async Task Me_returns_the_signed_in_person_and_their_levels() // Operations, D2
public async Task Me_returns_the_signed_in_person_and_their_levels() // Operations
{
var user = await AuthTestSupport.CreateUserAsync(factory.Services);
await AuthTestSupport.GrantAsync(factory.Services, user.Id, ScreenKeys.Users, AccessLevel.Editor);
Expand All @@ -30,7 +30,7 @@ public async Task Me_returns_the_signed_in_person_and_their_levels() // Operatio
}

[Fact]
public async Task Me_without_a_token_returns_401_session_expired() // AUTH-27, T7
public async Task Me_without_a_token_returns_401_session_expired() // AUTH-27
{
using var client = factory.CreateHttpsClient();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace ControlService.Api.IntegrationTests.Auth;

/// <summary>The real API with rate limits of two requests per window, so the third request is refused
/// without a test having to send dozens (T12).</summary>
/// without a test having to send dozens.</summary>
public sealed class RateLimitedApiFactory(PostgresContainerFixture postgres) : AuthApiFactory(postgres)
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public async Task Refresh_without_a_cookie_returns_401_session_expired() // AUTH
}

[Fact]
public async Task Refresh_of_a_deactivated_account_returns_401_account_inactive() // AUTH-18, D10
public async Task Refresh_of_a_deactivated_account_returns_401_account_inactive() // AUTH-18
{
var user = await AuthTestSupport.CreateUserAsync(factory.Services);
using var client = factory.CreateHttpsClient();
Expand Down
Loading
Loading