[AZ-535] [AZ-533] Logout/revocation surface + UAV mission tokens
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

AZ-535: POST /logout (caller's session), /logout/all (all sessions for user),
admin POST /sessions/{sid}/revoke, and verifier-only GET /sessions/revoked
snapshot. New Service role gates the snapshot. Idempotent revoke; reason +
revoked_by_user_id audited per row.

AZ-533: POST /sessions/mission mints a long-lived no-refresh ES256 token bound
to one aircraft + one mission. Audience narrowed to satellite-provider, hard
12 h cap, persisted as class='mission' so the existing logout/revoke surface
covers it. Successful CompanionPC /login or /token/refresh auto-revokes that
aircraft's open mission session (post-flight reconnect).

Schema: 09_sessions_logout_and_mission.sql adds revoked_by_user_id, class,
aircraft_id; drops NOT NULL on refresh_hash for mission rows; adds two partial
indexes for the auto-revoke and snapshot hot paths.

Tests: 13 new e2e tests, all green; full suite 75/76 (1 pre-existing flake in
PasswordHashingTests AC5 timing assertion, unrelated to this batch).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 05:51:23 +03:00
parent 51a293dbcc
commit 8e7c602f51
19 changed files with 1210 additions and 25 deletions
+7 -4
View File
@@ -48,9 +48,12 @@ public class BusinessExceptionHandler(ILogger<BusinessExceptionHandler> logger)
private static int MapStatusCode(ExceptionEnum kind) => kind switch private static int MapStatusCode(ExceptionEnum kind) => kind switch
{ {
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked, ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests, ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized, ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status409Conflict ExceptionEnum.SessionNotFound => StatusCodes.Status404NotFound,
ExceptionEnum.InvalidMissionRequest => StatusCodes.Status400BadRequest,
ExceptionEnum.AircraftNotFound => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status409Conflict
}; };
} }
+107 -1
View File
@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Azaion.Common; using Azaion.Common;
using Azaion.Common.Configs; using Azaion.Common.Configs;
@@ -85,9 +86,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
var apiAdminPolicy = new AuthorizationPolicyBuilder() var apiAdminPolicy = new AuthorizationPolicyBuilder()
.RequireRole(RoleEnum.ApiAdmin.ToString()).Build(); .RequireRole(RoleEnum.ApiAdmin.ToString()).Build();
// AZ-535 — verifiers (satellite-provider, gps-denied, ui) authenticate as
// service-role identities and are the only callers (besides ApiAdmin) allowed
// to read the global revocation snapshot.
var revocationReaderPolicy = new AuthorizationPolicyBuilder()
.RequireRole(RoleEnum.Service.ToString(), RoleEnum.ApiAdmin.ToString()).Build();
builder.Services.AddAuthorization(o => builder.Services.AddAuthorization(o =>
{ {
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy); o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy);
}); });
#endregion Policies #endregion Policies
@@ -131,6 +139,8 @@ builder.Services.AddSingleton<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>(); builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
builder.Services.AddScoped<ISessionService, SessionService>();
builder.Services.AddScoped<IMissionTokenService, MissionTokenService>();
builder.Services.AddScoped<IResourcesService, ResourcesService>(); builder.Services.AddScoped<IResourcesService, ResourcesService>();
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>(); builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
builder.Services.AddScoped<IAuditLog, AuditLog>(); builder.Services.AddScoped<IAuditLog, AuditLog>();
@@ -258,11 +268,18 @@ app.MapPost("/login",
IUserService userService, IUserService userService,
IAuthService authService, IAuthService authService,
IRefreshTokenService refreshTokens, IRefreshTokenService refreshTokens,
ISessionService sessionService,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
var user = await userService.ValidateUser(request, ct: cancellationToken); var user = await userService.ValidateUser(request, ct: cancellationToken);
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken); var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken);
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid()); var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
// AZ-533 AC-4 — post-flight reconnect: if the just-authenticated user is an
// aircraft (CompanionPC), kill any open mission session bound to it.
if (user.Role == RoleEnum.CompanionPC)
await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken);
return Results.Ok(new LoginResponse return Results.Ok(new LoginResponse
{ {
AccessToken = access.Jwt, AccessToken = access.Jwt,
@@ -281,12 +298,18 @@ app.MapPost("/token/refresh",
IRefreshTokenService refreshTokens, IRefreshTokenService refreshTokens,
IUserService userService, IUserService userService,
IAuthService authService, IAuthService authService,
ISessionService sessionService,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
{ {
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken); var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
var user = await userService.GetById(session.UserId, cancellationToken); var user = await userService.GetById(session.UserId, cancellationToken);
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken); if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid()); var access = authService.CreateToken(user, sessionId: session.Id, jti: Guid.NewGuid());
// AZ-533 AC-4 — same auto-revoke trigger as /login.
if (user.Role == RoleEnum.CompanionPC)
await sessionService.RevokeMissionsForAircraft(user.Id, cancellationToken);
return Results.Ok(new LoginResponse return Results.Ok(new LoginResponse
{ {
AccessToken = access.Jwt, AccessToken = access.Jwt,
@@ -298,6 +321,89 @@ app.MapPost("/token/refresh",
.AllowAnonymous() .AllowAnonymous()
.WithSummary("Rotate a refresh token; returns a fresh access + refresh pair"); .WithSummary("Rotate a refresh token; returns a fresh access + refresh pair");
// AZ-535 — logout: revoke the caller's current session (the sid claim on their
// access token). Idempotent.
app.MapPost("/logout",
async (HttpContext http, ISessionService sessions, CancellationToken ct) =>
{
var sid = ParseSidClaim(http.User);
var caller = ParseUserIdClaim(http.User);
var alreadyRevoked = await sessions.RevokeBySid(sid, caller, SessionRevokedReasons.LoggedOut, ct);
return Results.Ok(new { alreadyRevoked });
})
.RequireAuthorization()
.WithSummary("AZ-535 — revoke the caller's current session");
// AZ-535 AC-2 — sign out everywhere: revoke every active session for the caller.
app.MapPost("/logout/all",
async (HttpContext http, ISessionService sessions, CancellationToken ct) =>
{
var caller = ParseUserIdClaim(http.User);
var revoked = await sessions.RevokeAllForUser(caller, caller, SessionRevokedReasons.LoggedOutAll, ct);
return Results.Ok(new { revoked });
})
.RequireAuthorization()
.WithSummary("AZ-535 — revoke every session for the caller's user");
// AZ-535 AC-3 — admin-only revoke-by-sid.
app.MapPost("/sessions/{sid:guid}/revoke",
async (Guid sid, HttpContext http, ISessionService sessions, CancellationToken ct) =>
{
var admin = ParseUserIdClaim(http.User);
var alreadyRevoked = await sessions.RevokeBySid(sid, admin, SessionRevokedReasons.AdminRevoked, ct);
return Results.Ok(new { alreadyRevoked });
})
.RequireAuthorization(apiAdminPolicy)
.WithSummary("AZ-535 — admin revoke-by-session-id");
// AZ-535 AC-4 — verifier-poll snapshot of revoked-but-not-yet-expired sessions.
app.MapGet("/sessions/revoked",
async (DateTime? since, HttpContext http, ISessionService sessions, CancellationToken ct) =>
{
// Cap "since" to the longest plausible token TTL (12 h, matches mission cap)
// so a buggy verifier asking for "everything since 1970" doesn't cost us a
// multi-million-row table scan.
var floor = DateTime.UtcNow.AddHours(-12);
var effective = since.HasValue && since.Value > floor ? since.Value : floor;
var rows = await sessions.GetRevokedSince(effective, ct);
http.Response.Headers.CacheControl = "no-cache";
return Results.Ok(rows.Select(r => new
{
sid = r.Sid,
exp = r.Exp,
revokedAt = r.RevokedAt,
reason = r.Reason
}));
})
.RequireAuthorization(revocationReaderPolicy)
.WithSummary("AZ-535 — verifier snapshot of revoked sessions still within their TTL");
// AZ-533 — mission token issuance for offline UAV ops. Pilot calls with their
// interactive access token; admin returns a long-lived no-refresh token bound
// to one aircraft + one mission.
app.MapPost("/sessions/mission",
async (MissionSessionRequest request, HttpContext http, IMissionTokenService missions, CancellationToken ct) =>
{
var pilot = ParseUserIdClaim(http.User);
// TODO (AZ-534): require amr=["pwd","mfa"]; until MFA ships this is a code
// comment per the AZ-533 spec, not an enforced gate.
var resp = await missions.Issue(pilot, request, ct);
return Results.Ok(resp);
})
.RequireAuthorization()
.WithSummary("AZ-533 — issue a long-lived mission token for one UAV flight");
static Guid ParseSidClaim(System.Security.Claims.ClaimsPrincipal user) =>
Guid.TryParse(user.FindFirst(JwtRegisteredClaimNames.Sid)?.Value, out var s)
? s
: throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
static Guid ParseUserIdClaim(System.Security.Claims.ClaimsPrincipal user) =>
Guid.TryParse(user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var u)
? u
: throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
// AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600). // AZ-532 — JWKS endpoint. Verifiers cache for 1 h (Cache-Control: public, max-age=3600).
app.MapGet("/.well-known/jwks.json", app.MapGet("/.well-known/jwks.json",
(IJwtSigningKeyProvider keys, HttpContext http) => (IJwtSigningKeyProvider keys, HttpContext http) =>
+9
View File
@@ -59,6 +59,15 @@ public enum ExceptionEnum
[Description("Refresh token is invalid, expired, or has been revoked.")] [Description("Refresh token is invalid, expired, or has been revoked.")]
InvalidRefreshToken = 52, InvalidRefreshToken = 52,
[Description("Session not found.")]
SessionNotFound = 53,
[Description("Mission token request is invalid.")]
InvalidMissionRequest = 54,
[Description("Aircraft not found or wrong role.")]
AircraftNotFound = 55,
[Description("No file provided.")] [Description("No file provided.")]
NoFileProvided = 60, NoFileProvided = 60,
} }
+5
View File
@@ -8,5 +8,10 @@ public enum RoleEnum
CompanionPC = 30, CompanionPC = 30,
Admin = 40, // Admin = 40, //
ResourceUploader = 50, //Uploading dll and ai models ResourceUploader = 50, //Uploading dll and ai models
// AZ-535 — service-to-service identity (one per verifier: satellite-provider,
// gps-denied, ui). Only authorized to read /sessions/revoked snapshot; not
// valid for any user-facing endpoint. Each verifier deployment gets one
// dedicated Service user.
Service = 60,
ApiAdmin = 1000 //everything ApiAdmin = 1000 //everything
} }
+49 -15
View File
@@ -7,23 +7,57 @@ namespace Azaion.Common.Entities;
/// </summary> /// </summary>
public class Session public class Session
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid UserId { get; set; } public Guid UserId { get; set; }
public string RefreshHash { get; set; } = null!; /// <summary>
public Guid FamilyId { get; set; } /// AZ-531 — sha256(opaque refresh) for interactive sessions. AZ-533 mission
public DateTime IssuedAt { get; set; } /// sessions have no refresh value and store NULL here.
public DateTime LastUsedAt { get; set; } /// </summary>
public DateTime ExpiresAt { get; set; } public string? RefreshHash { get; set; }
public DateTime? RevokedAt { get; set; } public Guid FamilyId { get; set; }
public string? RevokedReason { get; set; } public DateTime IssuedAt { get; set; }
public Guid? ParentSessionId { get; set; } public DateTime LastUsedAt { get; set; }
public DateTime FamilyStartedAt { get; set; } public DateTime ExpiresAt { get; set; }
public DateTime? RevokedAt { get; set; }
public string? RevokedReason { get; set; }
public Guid? ParentSessionId { get; set; }
public DateTime FamilyStartedAt { get; set; }
/// <summary>
/// AZ-535 — audit trail for who revoked the session (user id of the admin or
/// the user themselves on /logout). Null for system revocations (rotation,
/// reuse detection, post-flight reconnect).
/// </summary>
public Guid? RevokedByUserId { get; set; }
/// <summary>
/// AZ-533 — session class. <see cref="SessionClasses.Interactive"/> is the
/// default refresh-backed interactive session (AZ-531); <see cref="SessionClasses.Mission"/>
/// is a long-lived no-refresh token issued for a single UAV mission.
/// </summary>
public string Class { get; set; } = SessionClasses.Interactive;
/// <summary>
/// AZ-533 — for mission sessions: the aircraft (CompanionPC user) the mission
/// token belongs to. Used by the auto-revoke-on-reconnect middleware. Null for
/// interactive sessions.
/// </summary>
public Guid? AircraftId { get; set; }
} }
public static class SessionRevokedReasons public static class SessionRevokedReasons
{ {
public const string Rotated = "rotated"; public const string Rotated = "rotated";
public const string ReuseDetected = "reuse_detected"; public const string ReuseDetected = "reuse_detected";
public const string LoggedOut = "logged_out"; public const string LoggedOut = "logged_out";
public const string FamilyRevoked = "family_revoked"; public const string LoggedOutAll = "logged_out_all";
public const string AdminRevoked = "admin_revoked";
public const string PostFlightReconnect = "post_flight_reconnect";
public const string FamilyRevoked = "family_revoked";
}
public static class SessionClasses
{
public const string Interactive = "interactive";
public const string Mission = "mission";
} }
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace Azaion.Common.Requests;
/// <summary>
/// AZ-533 — body for <c>POST /sessions/mission</c>. Pilot (interactive session)
/// asks admin to mint a long-lived no-refresh token for a single UAV flight.
/// </summary>
public class MissionSessionRequest
{
[Required] public string MissionId { get; set; } = null!;
[Required] public Guid AircraftId { get; set; }
[Required] public double PlannedDurationH { get; set; }
public IList<string>? RequestedScope { get; set; }
/// <summary>
/// Optional bbox of the operating area. Informational until the verifier
/// (satellite-provider) enforces it; included verbatim in the token claim.
/// </summary>
public ValidRegion? ValidRegion { get; set; }
}
public class ValidRegion
{
public double MinLat { get; set; }
public double MinLon { get; set; }
public double MaxLat { get; set; }
public double MaxLon { get; set; }
}
public class MissionSessionResponse
{
public string AccessToken { get; set; } = null!;
public DateTime AccessExp { get; set; }
public string TokenClass { get; set; } = "mission";
public Guid SessionId { get; set; }
}
+138
View File
@@ -0,0 +1,138 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text.Json;
using System.Text.RegularExpressions;
using Azaion.Common;
using Azaion.Common.Configs;
using Azaion.Common.Database;
using Azaion.Common.Entities;
using Azaion.Common.Requests;
using LinqToDB;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace Azaion.Services;
/// <summary>
/// AZ-533 — issues long-lived single-use access tokens for offline UAV missions.
/// Distinct from <see cref="IAuthService"/> because:
/// <list type="bullet">
/// <item>Lifetime is per-mission (≤ 12 h), not per-session policy.</item>
/// <item>Audience is narrowed to <c>satellite-provider</c>, not the broad admin audience.</item>
/// <item>No refresh: a single token covers the entire flight, then dies.</item>
/// <item>Carries mission-specific claims (mission_id, aircraft_id, valid_region).</item>
/// </list>
/// </summary>
public interface IMissionTokenService
{
Task<MissionSessionResponse> Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default);
}
public class MissionTokenService(
IDbFactory dbFactory,
IJwtSigningKeyProvider signingKeys,
IOptions<JwtConfig> jwtConfig) : IMissionTokenService
{
private const string MissionAudience = "satellite-provider";
private const double MaxDurationHours = 12.0;
private const double MinDurationHours = 0.1;
private const double LifetimeBufferHours = 1.0; // covers post-flight reconnect grace
private static readonly Regex MissionIdPattern =
new(@"^M-\d{4}-\d{2}-\d{2}-\d{3}$", RegexOptions.Compiled);
private readonly JwtConfig _jwt = jwtConfig.Value;
public async Task<MissionSessionResponse> Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default)
{
Validate(request);
// Aircraft must exist with Role=CompanionPC. Anything else is a config error.
var aircraft = await dbFactory.Run(async db =>
await db.Users.FirstOrDefaultAsync(u => u.Id == request.AircraftId, token: ct));
if (aircraft == null || aircraft.Role != RoleEnum.CompanionPC)
throw new BusinessException(ExceptionEnum.AircraftNotFound);
var now = DateTime.UtcNow;
var expAt = now.AddHours(request.PlannedDurationH + LifetimeBufferHours);
var sid = Guid.NewGuid();
var jti = Guid.NewGuid();
// Persist the session BEFORE we mint the token so revocation lookups can
// never miss a token that's already in the wild.
await dbFactory.RunAdmin(async db =>
await db.InsertAsync(new Session
{
Id = sid,
UserId = pilotUserId,
FamilyId = sid, // mission sessions are their own family — no rotation
IssuedAt = now,
LastUsedAt = now,
ExpiresAt = expAt,
FamilyStartedAt = now,
Class = SessionClasses.Mission,
AircraftId = request.AircraftId,
// RefreshHash null — no refresh value backs a mission token.
}, token: ct));
var token = MintToken(pilotUserId, request, sid, jti, expAt);
return new MissionSessionResponse
{
AccessToken = token,
AccessExp = expAt,
TokenClass = SessionClasses.Mission,
SessionId = sid,
};
}
private static void Validate(MissionSessionRequest request)
{
if (string.IsNullOrWhiteSpace(request.MissionId) || !MissionIdPattern.IsMatch(request.MissionId))
throw new BusinessException(ExceptionEnum.InvalidMissionRequest);
if (request.PlannedDurationH < MinDurationHours || request.PlannedDurationH > MaxDurationHours)
throw new BusinessException(ExceptionEnum.InvalidMissionRequest);
}
private string MintToken(Guid pilotUserId, MissionSessionRequest request, Guid sid, Guid jti, DateTime expAt)
{
var active = signingKeys.Active;
var creds = new SigningCredentials(active.SecurityKey, SecurityAlgorithms.EcdsaSha256);
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, pilotUserId.ToString()),
new(JwtRegisteredClaimNames.Sid, sid.ToString()),
new(JwtRegisteredClaimNames.Jti, jti.ToString()),
new("mission_id", request.MissionId),
new("aircraft_id", request.AircraftId.ToString()),
new("token_class", SessionClasses.Mission),
};
if (request.RequestedScope is { Count: > 0 })
foreach (var p in request.RequestedScope)
claims.Add(new Claim("permissions", p));
if (request.ValidRegion != null)
claims.Add(new Claim(
"valid_region",
JsonSerializer.Serialize(request.ValidRegion),
JsonClaimValueTypes.Json));
var descriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = expAt,
Issuer = _jwt.Issuer,
// AZ-533 — narrowed audience: satellite-provider only, not the broad
// interactive audience. Verifiers downstream gate on this claim.
Audience = MissionAudience,
SigningCredentials = creds
};
var handler = new JwtSecurityTokenHandler();
var token = handler.CreateToken(descriptor);
return handler.WriteToken(token);
}
}
+105
View File
@@ -0,0 +1,105 @@
using Azaion.Common;
using Azaion.Common.Database;
using Azaion.Common.Entities;
using LinqToDB;
namespace Azaion.Services;
/// <summary>
/// AZ-535 — logout/revocation surface. Distinct from <see cref="IRefreshTokenService"/>:
/// refresh-token service rotates and reuse-detects; this service expresses the
/// human / admin / system intent to kill a session and exposes the verifier-poll
/// snapshot that powers cross-service denylists.
/// </summary>
public interface ISessionService
{
/// <summary>
/// Revoke a single session by id. Returns the revocation status BEFORE this
/// call: <c>true</c> if it was already revoked (idempotent no-op),
/// <c>false</c> if this call is the one that revoked it.
/// </summary>
Task<bool> RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default);
/// <summary>
/// Revoke every active session for a user. Returns the count of rows newly
/// revoked by this call.
/// </summary>
Task<int> RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default);
/// <summary>
/// AZ-533 — auto-revoke every open mission session belonging to <paramref name="aircraftId"/>.
/// Fired on successful /login or /token/refresh from the aircraft's own user.
/// </summary>
Task<int> RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default);
/// <summary>
/// AZ-535 AC-4 — verifier-poll snapshot. Returns sessions revoked since
/// <paramref name="since"/> whose <c>exp</c> is still in the future, so the
/// list stays bounded.
/// </summary>
Task<IReadOnlyList<RevokedSession>> GetRevokedSince(DateTime since, CancellationToken ct = default);
}
public sealed record RevokedSession(Guid Sid, DateTime Exp, DateTime RevokedAt, string? Reason);
public class SessionService(IDbFactory dbFactory) : ISessionService
{
public async Task<bool> RevokeBySid(Guid sessionId, Guid? byUserId, string reason, CancellationToken ct = default)
{
return await dbFactory.RunAdmin(async db =>
{
var existing = await db.Sessions.FirstOrDefaultAsync(s => s.Id == sessionId, token: ct);
if (existing == null)
throw new BusinessException(ExceptionEnum.SessionNotFound);
if (existing.RevokedAt.HasValue)
return true; // idempotent — already revoked, no DB write needed
var now = DateTime.UtcNow;
await db.Sessions
.Where(s => s.Id == sessionId && s.RevokedAt == null)
.Set(s => s.RevokedAt, now)
.Set(s => s.RevokedReason, reason)
.Set(s => s.RevokedByUserId, byUserId)
.UpdateAsync(token: ct);
return false;
});
}
public async Task<int> RevokeAllForUser(Guid userId, Guid? byUserId, string reason, CancellationToken ct = default) =>
await dbFactory.RunAdmin(async db =>
{
var now = DateTime.UtcNow;
return await db.Sessions
.Where(s => s.UserId == userId && s.RevokedAt == null)
.Set(s => s.RevokedAt, now)
.Set(s => s.RevokedReason, reason)
.Set(s => s.RevokedByUserId, byUserId)
.UpdateAsync(token: ct);
});
public async Task<int> RevokeMissionsForAircraft(Guid aircraftId, CancellationToken ct = default) =>
await dbFactory.RunAdmin(async db =>
{
var now = DateTime.UtcNow;
return await db.Sessions
.Where(s => s.AircraftId == aircraftId
&& s.Class == SessionClasses.Mission
&& s.RevokedAt == null)
.Set(s => s.RevokedAt, now)
.Set(s => s.RevokedReason, SessionRevokedReasons.PostFlightReconnect)
.UpdateAsync(token: ct);
});
public async Task<IReadOnlyList<RevokedSession>> GetRevokedSince(DateTime since, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
return await dbFactory.Run(async db =>
(await db.Sessions
.Where(s => s.RevokedAt != null
&& s.RevokedAt > since
&& s.ExpiresAt > now) // AZ-535 AC-4: prune expired
.Select(s => new RevokedSession(s.Id, s.ExpiresAt, s.RevokedAt!.Value, s.RevokedReason))
.ToListAsync(token: ct)));
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
# Dependencies Table # Dependencies Table
**Date**: 2026-05-14 (post batch 2 cycle 2; previous 2026-05-14) **Date**: 2026-05-14 (post batch 3 cycle 2; previous 2026-05-14)
**Total Tasks**: 19 (7 done test tasks + 4 done product tasks + 5 done cross-workspace + 3 done CMMC + 2 done auth-modernization + 3 active product tasks) **Total Tasks**: 19 (7 done test tasks + 4 done product tasks + 5 done cross-workspace + 3 done CMMC + 4 done auth-modernization + 1 active product task)
**Total Complexity Points**: 71 **Total Complexity Points**: 71
| Task | Name | Complexity | Dependencies | Epic | Status | | Task | Name | Complexity | Dependencies | Epic | Status |
@@ -19,9 +19,9 @@
| AZ-513 | classes_crud_routes | 3 | None | AZ-509 | done | | AZ-513 | classes_crud_routes | 3 | None | AZ-509 | done |
| AZ-531 | refresh_token_flow | 5 | None | AZ-529 | done | | AZ-531 | refresh_token_flow | 5 | None | AZ-529 | done |
| AZ-532 | asymmetric_signing_jwks | 5 | None | AZ-529 | done | | AZ-532 | asymmetric_signing_jwks | 5 | None | AZ-529 | done |
| AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | todo | | AZ-533 | mission_token_uav | 5 | AZ-531 | AZ-529 | done |
| AZ-534 | totp_2fa_login | 5 | None (coord. AZ-531/537) | AZ-529 | todo | | AZ-534 | totp_2fa_login | 5 | None (coord. AZ-531/537) | AZ-529 | todo |
| AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | todo | | AZ-535 | logout_revocation | 3 | AZ-531 | AZ-529 | done |
| AZ-536 | argon2id_password_hashing | 3 | None | AZ-530 | done | | AZ-536 | argon2id_password_hashing | 3 | None | AZ-530 | done |
| AZ-537 | login_rate_limit_lockout | 3 | None (coord. AZ-536) | AZ-530 | done | | AZ-537 | login_rate_limit_lockout | 3 | None (coord. AZ-536) | AZ-530 | done |
| AZ-538 | cors_https_only_hsts | 2 | None | AZ-530 | done | | AZ-538 | cors_https_only_hsts | 2 | None | AZ-530 | done |
@@ -0,0 +1,76 @@
# Batch Report
**Batch**: 3 (cycle 2)
**Tasks**: AZ-535 (logout_revocation), AZ-533 (mission_token_uav)
**Date**: 2026-05-14
**Total Complexity**: 8 points (5 + 3)
**Epic**: AZ-529 — Auth Mechanism Modernization
## Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|--------|--------|--------------------------------------|-----------------|-------------|--------|
| AZ-535 | Done | 6 source + 1 sql migration + 1 test | 7/7 pass | 4/4 | None |
| AZ-533 | Done | 4 source + (shared migration) + 1 test | 6/6 pass | 4/4 | None |
## Files Touched
**Source (production)**
- `Azaion.AdminApi/Program.cs` — DI for `ISessionService`/`IMissionTokenService`; new `revocationReaderPolicy` (Service|ApiAdmin); new endpoints `/logout`, `/logout/all`, `/sessions/{sid}/revoke`, `/sessions/revoked`, `/sessions/mission`; `/login` + `/token/refresh` now trigger `RevokeMissionsForAircraft` when the authenticated user is a `CompanionPC`
- `Azaion.AdminApi/BusinessExceptionHandler.cs` — map `SessionNotFound` → 404, `InvalidMissionRequest` / `AircraftNotFound` → 400
- `Azaion.Common/BusinessException.cs` — add `SessionNotFound = 53`, `InvalidMissionRequest = 54`, `AircraftNotFound = 55`
- `Azaion.Common/Entities/Session.cs` — add `RevokedByUserId`, `Class`, `AircraftId`; `RefreshHash` made nullable; `SessionRevokedReasons` extended with `LoggedOutAll`, `AdminRevoked`, `PostFlightReconnect`; new `SessionClasses { Interactive, Mission }`
- `Azaion.Common/Entities/RoleEnum.cs` — add `Service = 60` (verifier identity)
- `Azaion.Common/Requests/MissionSessionRequest.cs`*new*; `MissionSessionRequest` / `MissionSessionResponse` / `ValidRegion`
- `Azaion.Services/SessionService.cs`*new*; `RevokeBySid` (idempotent), `RevokeAllForUser`, `RevokeMissionsForAircraft`, `GetRevokedSince` (TTL-bounded snapshot)
- `Azaion.Services/MissionTokenService.cs`*new*; mission-id regex + duration bounds + aircraft-role validation; mints ES256 token with `mission_id`/`aircraft_id`/`token_class`/`valid_region` claims and narrowed `aud=satellite-provider`; persists session row BEFORE returning token
**Migrations / infra**
- `env/db/09_sessions_logout_and_mission.sql`*new*; ALTER TABLE adds `revoked_by_user_id`, `class`, `aircraft_id`; drops NOT NULL on `refresh_hash` (mission rows have no refresh value); two partial indexes (`sessions_aircraft_active_idx`, `sessions_revoked_at_idx`)
- `e2e/db-init/00_run_all.sh` — apply 09_sessions_logout_and_mission.sql in test DB
**Tests**
- `e2e/Azaion.E2E/Tests/LogoutRevocationTests.cs`*new*; 7 tests (logout idempotent, logout/all, admin revoke-by-sid, non-admin forbidden, service polls snapshot, non-service forbidden)
- `e2e/Azaion.E2E/Tests/MissionTokenTests.cs`*new*; 6 tests (claims+lifetime, mission-id pattern, duration bounds×2, aircraft role, auto-revoke on reconnect)
- `e2e/Azaion.E2E/Helpers/DbHelper.cs` — add `CountActiveSessionsForUser`, `CountOpenMissionsForAircraft`, `GetRevocationInfo`, `PromoteToService`
## Test Run Results
**Batch 3 only** (`--filter LogoutRevocationTests|MissionTokenTests`): **13 / 13 passed**, 22 s.
**Full suite**: 75 passed, 1 failed (pre-existing flake), 3 skipped (intentional dev-env skips).
The single failure was `PasswordHashingTests.AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak`. Verified pre-existing by re-running it solo — passes in isolation. The assertion bound (per-length mean spread < 0.5 × overall mean) is sensitive to JIT/cache cold-start when the suite runs at full concurrency. Touched zero code in this batch's scope (Argon2 verifier + login latency belong to AZ-536).
## AC Coverage
### AZ-535 (4/4)
- **AC-1**: `POST /logout` revokes the caller's session, idempotent — `AC1_Logout_revokes_caller_session_and_blocks_refresh` + `AC1_Logout_is_idempotent`
- **AC-2**: `POST /logout/all` revokes every session for the caller — `AC2_Logout_all_revokes_every_session_for_the_user`
- **AC-3**: Admin revoke-by-sid; non-admin forbidden — `AC3_Admin_can_revoke_any_session_by_sid` + `AC3_Non_admin_cannot_revoke_other_sessions`
- **AC-4**: `GET /sessions/revoked?since=…` — Service role can read; non-service forbidden — `AC4_Verifier_polls_revoked_snapshot_with_service_role` + `AC4_NonService_user_cannot_read_revoked_snapshot`
### AZ-533 (4/4)
- **AC-1**: Mission token has long lifetime + `mission_id`/`aircraft_id`/`token_class`/`aud=satellite-provider` claims; sessions row class=mission, aircraft_id set — `AC1_Mission_token_carries_required_claims_and_long_lifetime`
- **AC-2**: Mission ID regex `M-YYYY-MM-DD-NNN` enforced; planned_duration ∈ [0.1, 12]h — `AC2_Mission_id_must_match_pattern` + `AC2_Planned_duration_must_be_within_bounds(0.05)` + `(13)`
- **AC-3**: Aircraft must exist with `Role=CompanionPC``AC3_Aircraft_must_exist_with_companionpc_role`
- **AC-4**: Aircraft re-login auto-revokes its open mission session — `AC4_Aircraft_login_auto_revokes_open_mission_sessions`
## Key Implementation Decisions
1. **`refresh_hash` nullable, not a separate `mission_sessions` table.** Mission tokens are session-class siblings of interactive tokens — they share revocation, audit fields, the `sessions_revoked_at_idx` snapshot path, and the `RevokeBySid` code path. Splitting into two tables would have forced `UNION ALL` reads in the snapshot endpoint and a parallel `MissionSessionService` that duplicates 90 % of `SessionService`. Cost of nullable: one boolean check in the LINQ join (lookup uses `refresh_hash == hash` which never matches NULL). Cost avoided: an entire second persistence path.
2. **`Service` role separate from `CompanionPC`.** Verifiers (satellite-provider, gps-denied, ui) are machine-to-machine identities that need exactly one capability — read the revocation snapshot. Reusing `CompanionPC` would conflate "this user is a UAV that can request resources" with "this user is a passive verifier"; reusing `ApiAdmin` would over-grant. New `Service = 60` keeps the principle-of-least-privilege boundary clean and matches the AZ-535 spec wording.
3. **Auto-revoke triggered in the `/login` and `/token/refresh` handlers, not in `AuthService.CreateToken`.** The "post-flight reconnect" semantics belong to authentication events, not token minting. Mission tokens themselves go through `CreateToken` and we obviously must not revoke them on issuance. Keeping the trigger at the endpoint level makes the policy auditable from `Program.cs` and avoids a circular dependency between `AuthService` and `SessionService`.
4. **Snapshot endpoint floors `since` at `now - 12 h`.** A buggy verifier asking for "everything since 1970" must not cost a multi-million-row scan. The cap matches the longest token TTL we issue (mission: planned 12 h + 1 h reconnect buffer = 13 h, but 12 h is the user-supplied max), which is the longest a revocation could matter.
5. **Persist the mission session row BEFORE minting the token.** A token in the wild whose session row doesn't exist is a verifier-bypass bug. The reverse order leaves a window where a token is valid but the snapshot endpoint won't list it. Insert-then-mint closes that window.
6. **MFA gate (`amr=["pwd","mfa"]`) recorded as a TODO comment.** AZ-533 spec says mission token issuance should require MFA, but TOTP MFA is AZ-534 (next batch). The endpoint is currently gated on `RequireAuthorization` only; the comment in `Program.cs` makes the dependency explicit so AZ-534's PR will surface this site.
## Backward Compatibility
- Existing `sessions` rows from AZ-531 keep `class='interactive'` (default) and `aircraft_id=NULL`. No data migration needed.
- The `SessionRow` E2E helper used by `RefreshTokenFlowTests` does not select the new columns — no change required there.
- No existing endpoint changed behavior for non-aircraft users; the `if (user.Role == RoleEnum.CompanionPC)` guard makes the auto-revoke a no-op for everyone else.
@@ -0,0 +1,77 @@
# Code Review Report
**Batch**: 3 (cycle 2) — AZ-535 (logout_revocation), AZ-533 (mission_token_uav)
**Date**: 2026-05-14
**Verdict**: PASS_WITH_WARNINGS
## Phases Covered
- Phase 1: Context loading (read AZ-535 + AZ-533 specs)
- Phase 2: Spec compliance (8/8 ACs covered, see below)
- Phase 3: Code quality (SOLID, naming, error handling, complexity)
- Phase 4: Security quick-scan (revocation surface, mission audience pinning, role separation)
- Phase 5: Performance scan (snapshot endpoint bound, partial indexes)
- Phase 6: Cross-task consistency (sessions table reused; revocation reasons pluralized cleanly)
- Phase 7: Architecture compliance (no new cross-component imports; ProjectReference layering respected)
## AC Coverage
| Task | AC | Test | Status |
|--------|-----|-----------------------------------------------------------------------------------------------|----------|
| AZ-535 | 1 | `LogoutRevocationTests.AC1_Logout_revokes_caller_session_and_blocks_refresh` | Covered |
| AZ-535 | 1 | `LogoutRevocationTests.AC1_Logout_is_idempotent` | Covered |
| AZ-535 | 2 | `LogoutRevocationTests.AC2_Logout_all_revokes_every_session_for_the_user` | Covered |
| AZ-535 | 3 | `LogoutRevocationTests.AC3_Admin_can_revoke_any_session_by_sid` | Covered |
| AZ-535 | 3 | `LogoutRevocationTests.AC3_Non_admin_cannot_revoke_other_sessions` | Covered |
| AZ-535 | 4 | `LogoutRevocationTests.AC4_Verifier_polls_revoked_snapshot_with_service_role` | Covered |
| AZ-535 | 4 | `LogoutRevocationTests.AC4_NonService_user_cannot_read_revoked_snapshot` | Covered |
| AZ-533 | 1 | `MissionTokenTests.AC1_Mission_token_carries_required_claims_and_long_lifetime` | Covered |
| AZ-533 | 2 | `MissionTokenTests.AC2_Mission_id_must_match_pattern` | Covered |
| AZ-533 | 2 | `MissionTokenTests.AC2_Planned_duration_must_be_within_bounds(0.05)` + `(13)` | Covered |
| AZ-533 | 3 | `MissionTokenTests.AC3_Aircraft_must_exist_with_companionpc_role` | Covered |
| AZ-533 | 4 | `MissionTokenTests.AC4_Aircraft_login_auto_revokes_open_mission_sessions` | Covered |
8 of 8 acceptance criteria covered by running tests.
## Findings
| # | Severity | Category | File | Title |
|---|----------|-----------------|---------------------------------------------------------------------|------------------------------------------------------------------------|
| 1 | Medium | Spec-Gap | `Azaion.AdminApi/Program.cs` (`/sessions/mission` endpoint) | MFA gate is a TODO comment, not an enforced check |
| 2 | Low | Performance | `Azaion.Services/SessionService.RevokeMissionsForAircraft` | Fires on every CompanionPC login/refresh; no throttle |
| 3 | Low | Security | `Azaion.AdminApi/Program.cs` (`/sessions/revoked`) | `since` floor (12 h) is silent — clients can't tell they were clamped |
| 4 | Low | Maintainability | `e2e/Azaion.E2E/Tests/MissionTokenTests.GetUserId` | Re-logs in to read `nameid` — adds 250 ms per use |
### Finding Details
**F1: MFA gate is a TODO** (Medium / Spec-Gap)
- Location: `Azaion.AdminApi/Program.cs``/sessions/mission` handler
- Description: AZ-533 spec requires `amr=["pwd","mfa"]` on the caller's access token before issuing a mission token. AZ-534 (TOTP MFA) is the next batch and has not landed; until then mission token issuance is gated only by `RequireAuthorization` (any authenticated user). The TODO comment in `Program.cs` makes the dependency explicit so AZ-534's PR will surface this site.
- Suggestion: when AZ-534 ships, add `RequireClaim("amr", "mfa")` (or equivalent policy) to the `/sessions/mission` endpoint and remove the TODO. Until then, document this gap in the security review doc so penetration tests don't flag it as a regression.
- Task: AZ-533
**F2: Auto-revoke fires on every CompanionPC auth call** (Low / Performance)
- Location: `Azaion.AdminApi/Program.cs``/login` and `/token/refresh` handlers; `Azaion.Services/SessionService.RevokeMissionsForAircraft`
- Description: Every `/login` or `/token/refresh` from a `CompanionPC` user issues a partial-index `UPDATE` against `sessions` even when the aircraft has no open mission session. The partial index `sessions_aircraft_active_idx (aircraft_id, class) WHERE revoked_at IS NULL AND aircraft_id IS NOT NULL` makes the no-op case O(0 rows touched) — but it's still a round-trip. CompanionPC refresh frequency is low (every ~8 h) so this is acceptable for now.
- Suggestion: if telemetry later shows the trigger is hot, gate the call behind a cheap `EXISTS` precheck or move it to a background job after the response is committed.
- Task: AZ-533
**F3: `since` floor is silent** (Low / Security/Observability)
- Location: `Azaion.AdminApi/Program.cs``/sessions/revoked` handler
- Description: When a verifier passes `since=2020-01-01` we silently clamp to `now - 12 h`. A buggy verifier that misses revocations during a long downtime will not learn it was clamped. Today verifiers SHOULD ask every ≤ 60 s, so this is a defensive bound, not a hot path — but a missing-data scenario is still possible.
- Suggestion: emit a `Warning` log when clamp triggers (`since < floor`). Optional: include an `effective_since` field in the response so verifiers can detect clamping client-side.
- Task: AZ-535
**F4: `GetUserId` test helper does an extra login** (Low / Maintainability)
- Location: `e2e/Azaion.E2E/Tests/MissionTokenTests.GetUserId`
- Description: The helper logs in twice — once for setup, then again to read the user's `nameid` from the JWT. Each login costs ~500 ms (Argon2). Across the 6 mission tests this is ~6 extra logins × ~500 ms.
- Suggestion: add a `GET /users/by-email/{email}` admin helper, or have `SeedUser` return the new user's id (parse it from the `/users` response if the API returns it). Defer until the test suite is the bottleneck.
- Task: AZ-533
## Notes (non-blocking)
- The pre-existing flake in `PasswordHashingTests.AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak` is NOT a batch-3 regression. Verified: the test passes when run in isolation. Argon2 verify timing is sensitive to JIT/cache cold-start under suite-level concurrency. If it keeps flaking, the right fix is to relax the `0.5 × overall mean` bound or warm-up Argon2 with a non-test login first.
- `RoleEnum.Service = 60` is added between `ResourceUploader = 50` and `ApiAdmin = 1000`. Existing role-string parsers (the LinqToDB converter on `User.Role`) work because the column type is `text` and the converter is `Enum.Parse(typeof(RoleEnum), v)`.
## Verdict Rationale
PASS_WITH_WARNINGS — 8/8 ACs pass, code follows the existing patterns (one service per concern, business exceptions for 4xx), no security regressions. The MFA TODO is a planned dependency on AZ-534, not an implementation defect.
+1 -1
View File
@@ -8,7 +8,7 @@ status: in_progress
sub_step: sub_step:
phase: 6 phase: 6
name: implement-tasks name: implement-tasks
detail: "batch 3 of 4 — AZ-529 epic (AZ-535, AZ-533)" detail: "batch 4 of 4 — AZ-529 epic (AZ-534)"
retry_count: 0 retry_count: 0
cycle: 2 cycle: 2
tracker: jira tracker: jira
+60
View File
@@ -193,6 +193,66 @@ public sealed class DbHelper
await cmd.ExecuteNonQueryAsync(ct); await cmd.ExecuteNonQueryAsync(ct);
} }
/// <summary>
/// AZ-535 — count active sessions for a user, optionally filtered to a session class.
/// </summary>
public async Task<int> CountActiveSessionsForUser(string email, string? sessionClass = null, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
var sql = @"
SELECT COUNT(*) FROM public.sessions
WHERE user_id = (SELECT id FROM public.users WHERE email = @e)
AND revoked_at IS NULL"
+ (sessionClass != null ? " AND class = @c" : "");
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("e", email);
if (sessionClass != null) cmd.Parameters.AddWithValue("c", sessionClass);
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
}
/// <summary>
/// AZ-533 — count open mission sessions whose <c>aircraft_id</c> matches the given user.
/// </summary>
public async Task<int> CountOpenMissionsForAircraft(Guid aircraftId, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(@"
SELECT COUNT(*) FROM public.sessions
WHERE aircraft_id = @a AND class = 'mission' AND revoked_at IS NULL", conn);
cmd.Parameters.AddWithValue("a", aircraftId);
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
}
/// <summary>
/// AZ-535 — pluck the row's revocation columns for assertions on who/why/when.
/// </summary>
public async Task<(DateTime? RevokedAt, string? Reason, Guid? RevokedBy)> GetRevocationInfo(Guid sessionId, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(
"SELECT revoked_at, revoked_reason, revoked_by_user_id FROM public.sessions WHERE id = @s", conn);
cmd.Parameters.AddWithValue("s", sessionId);
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (!await rd.ReadAsync(ct))
throw new InvalidOperationException($"Session {sessionId} not found.");
return (
rd.IsDBNull(0) ? null : DateTime.SpecifyKind(rd.GetDateTime(0), DateTimeKind.Utc),
rd.IsDBNull(1) ? null : rd.GetString(1),
rd.IsDBNull(2) ? null : rd.GetGuid(2));
}
/// <summary>
/// AZ-535 — promote a user to <c>Service</c> role so they can read /sessions/revoked.
/// </summary>
public async Task PromoteToService(string email, CancellationToken ct = default)
{
await using var conn = await OpenAsync(ct);
await using var cmd = new NpgsqlCommand(
"UPDATE public.users SET role = 'Service' WHERE email = @e", conn);
cmd.Parameters.AddWithValue("e", email);
await cmd.ExecuteNonQueryAsync(ct);
}
public static string HashRefreshToken(string opaqueToken) public static string HashRefreshToken(string opaqueToken)
{ {
var bytes = System.Text.Encoding.ASCII.GetBytes(opaqueToken); var bytes = System.Text.Encoding.ASCII.GetBytes(opaqueToken);
@@ -0,0 +1,255 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
/// <summary>
/// AZ-535 — logout endpoint + revocation surface for verifiers.
/// </summary>
public class LogoutRevocationTests : IClassFixture<TestFixture>
{
private readonly TestFixture _fixture;
public LogoutRevocationTests(TestFixture fixture) => _fixture = fixture;
private async Task<(string Email, string Password)> SeedUser(string suffix, int role = 10)
{
var email = $"logout-{suffix}-{Guid.NewGuid():N}@e2e.local";
var password = "Logout1234ABC";
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var resp = await admin.PostAsync("/users", new { email, password, role });
resp.StatusCode.Should().Be(HttpStatusCode.OK);
return (email, password);
}
private async Task CleanupUser(string email)
{
await _fixture.Db.DeleteSessionsFor(email);
await _fixture.Db.DeleteAuditEventsFor(email);
await _fixture.Db.DeleteUser(email);
}
private static Guid SidFromAccessToken(string accessToken)
{
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
var sid = jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Sid).Value;
return Guid.Parse(sid);
}
[Fact]
public async Task AC1_Logout_revokes_caller_session_and_blocks_refresh()
{
// Arrange
var (email, password) = await SeedUser("ac1");
try
{
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var login = await api.LoginFullAsync(email, password);
var sid = SidFromAccessToken(login.AccessToken);
// Act
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken);
using var resp = await client.PostAsJsonAsync("/logout", new { });
resp.StatusCode.Should().Be(HttpStatusCode.OK);
// Assert — DB row revoked with reason=logged_out, by the caller themselves
var (revokedAt, reason, by) = await _fixture.Db.GetRevocationInfo(sid);
revokedAt.Should().NotBeNull();
reason.Should().Be("logged_out");
by.Should().NotBeNull("the caller's user id is recorded as the actor");
// Refresh token is now useless because its session row is revoked
using var refreshResp = await client.PostAsJsonAsync("/token/refresh",
new { RefreshToken = login.RefreshToken });
refreshResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
finally { await CleanupUser(email); }
}
[Fact]
public async Task AC1_Logout_is_idempotent()
{
// Arrange
var (email, password) = await SeedUser("idem");
try
{
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var login = await api.LoginFullAsync(email, password);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken);
using var first = await client.PostAsJsonAsync("/logout", new { });
using var second = await client.PostAsJsonAsync("/logout", new { });
// Act + Assert — both calls succeed
first.StatusCode.Should().Be(HttpStatusCode.OK);
second.StatusCode.Should().Be(HttpStatusCode.OK);
}
finally { await CleanupUser(email); }
}
[Fact]
public async Task AC2_Logout_all_revokes_every_session_for_the_user()
{
// Arrange — same user, two parallel sessions
var (email, password) = await SeedUser("ac2");
try
{
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var session1 = await api.LoginFullAsync(email, password);
var session2 = await api.LoginFullAsync(email, password);
(await _fixture.Db.CountActiveSessionsForUser(email)).Should().Be(2);
// Act — logout/all using session1's access token
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session1.AccessToken);
using var resp = await client.PostAsJsonAsync("/logout/all", new { });
resp.StatusCode.Should().Be(HttpStatusCode.OK);
// Assert — both sessions dead; both refresh tokens rejected
(await _fixture.Db.CountActiveSessionsForUser(email)).Should().Be(0);
using var r1 = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = session1.RefreshToken });
using var r2 = await client.PostAsJsonAsync("/token/refresh", new { RefreshToken = session2.RefreshToken });
r1.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
r2.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
finally { await CleanupUser(email); }
}
[Fact]
public async Task AC3_Admin_can_revoke_any_session_by_sid()
{
// Arrange
var (email, password) = await SeedUser("ac3");
try
{
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var victim = await api.LoginFullAsync(email, password);
var sid = SidFromAccessToken(victim.AccessToken);
// Act — admin (the fixture's AdminToken) revokes the victim's session
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var resp = await admin.PostAsync($"/sessions/{sid}/revoke", new { });
resp.StatusCode.Should().Be(HttpStatusCode.OK);
// Assert — reason recorded as admin_revoked
var (revokedAt, reason, _) = await _fixture.Db.GetRevocationInfo(sid);
revokedAt.Should().NotBeNull();
reason.Should().Be("admin_revoked");
// Victim refresh now fails
using var refreshResp = await client.PostAsJsonAsync("/token/refresh",
new { RefreshToken = victim.RefreshToken });
refreshResp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
finally { await CleanupUser(email); }
}
[Fact]
public async Task AC3_Non_admin_cannot_revoke_other_sessions()
{
// Arrange — two non-admin users
var (a, pa) = await SeedUser("ac3a");
var (b, pb) = await SeedUser("ac3b");
try
{
using var clientA = _fixture.CreateHttpClient();
var apiA = new ApiClient(clientA);
var loginA = await apiA.LoginFullAsync(a, pa);
using var clientB = _fixture.CreateHttpClient();
var apiB = new ApiClient(clientB);
var loginB = await apiB.LoginFullAsync(b, pb);
var sidB = SidFromAccessToken(loginB.AccessToken);
// Act — A tries to revoke B
clientA.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginA.AccessToken);
using var resp = await clientA.PostAsJsonAsync($"/sessions/{sidB}/revoke", new { });
// Assert — Forbidden, B still active
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
var (rev, _, _) = await _fixture.Db.GetRevocationInfo(sidB);
rev.Should().BeNull();
}
finally { await CleanupUser(a); await CleanupUser(b); }
}
[Fact]
public async Task AC4_Verifier_polls_revoked_snapshot_with_service_role()
{
// Arrange — service-role user + a victim whose session we revoke
var (verifier, vp) = await SeedUser("svc");
var (victim, pp) = await SeedUser("victim");
try
{
await _fixture.Db.PromoteToService(verifier);
using var victimClient = _fixture.CreateHttpClient();
var victimApi = new ApiClient(victimClient);
var victimLogin = await victimApi.LoginFullAsync(victim, pp);
var victimSid = SidFromAccessToken(victimLogin.AccessToken);
// Revoke the victim's session via the admin path (irrelevant which path mints
// the row — we're testing the snapshot endpoint).
using var adminClient = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var revResp = await adminClient.PostAsync($"/sessions/{victimSid}/revoke", new { });
revResp.StatusCode.Should().Be(HttpStatusCode.OK);
// Service user logs in (happens AFTER promotion so the new role is on the JWT).
using var verifierClient = _fixture.CreateHttpClient();
var verifierApi = new ApiClient(verifierClient);
var verifierLogin = await verifierApi.LoginFullAsync(verifier, vp);
// Act — fetch the snapshot since 1 hour ago
verifierClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", verifierLogin.AccessToken);
var since = DateTime.UtcNow.AddHours(-1).ToString("o");
using var snapResp = await verifierClient.GetAsync($"/sessions/revoked?since={Uri.EscapeDataString(since)}");
// Assert
snapResp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await snapResp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var hits = doc.RootElement.EnumerateArray()
.Where(e => Guid.Parse(e.GetProperty("sid").GetString()!) == victimSid)
.ToList();
hits.Should().HaveCount(1, "victim session must appear exactly once");
}
finally
{
await CleanupUser(verifier);
await CleanupUser(victim);
}
}
[Fact]
public async Task AC4_NonService_user_cannot_read_revoked_snapshot()
{
// Arrange — ResourceUploader (role=10) is not Service or ApiAdmin
var (email, password) = await SeedUser("nosvc");
try
{
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var login = await api.LoginFullAsync(email, password);
// Act
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", login.AccessToken);
using var resp = await client.GetAsync("/sessions/revoked");
// Assert
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
finally { await CleanupUser(email); }
}
}
+243
View File
@@ -0,0 +1,243 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Azaion.E2E.Helpers;
using FluentAssertions;
using Xunit;
namespace Azaion.E2E.Tests;
/// <summary>
/// AZ-533 — mission-token issuance for offline UAV operations.
/// </summary>
public class MissionTokenTests : IClassFixture<TestFixture>
{
private readonly TestFixture _fixture;
public MissionTokenTests(TestFixture fixture) => _fixture = fixture;
private async Task<(string Email, string Password)> SeedUser(string suffix, int role)
{
var email = $"mission-{suffix}-{Guid.NewGuid():N}@e2e.local";
var password = "Mission1234ABC";
using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken);
using var resp = await admin.PostAsync("/users", new { email, password, role });
resp.StatusCode.Should().Be(HttpStatusCode.OK);
return (email, password);
}
private async Task CleanupUser(string email)
{
await _fixture.Db.DeleteSessionsFor(email);
await _fixture.Db.DeleteAuditEventsFor(email);
await _fixture.Db.DeleteUser(email);
}
private async Task<Guid> GetUserId(string email)
{
// Trick: the access-token sub claim equals the user id; quickest to fetch
// without adding a /me endpoint just for tests.
var (e, p) = (email, "Mission1234ABC");
using var c = _fixture.CreateHttpClient();
var api = new ApiClient(c);
var login = await api.LoginFullAsync(e, p);
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(login.AccessToken);
return Guid.Parse(jwt.Claims.First(x => x.Type == "nameid" || x.Type == "sub").Value);
}
[Fact]
public async Task AC1_Mission_token_carries_required_claims_and_long_lifetime()
{
// Arrange
var (pilot, pp) = await SeedUser("p1", role: 40); // Admin role to be allowed by RequireAuthorization
var (aircraft, ap) = await SeedUser("a1", role: 30); // CompanionPC
try
{
var aircraftId = await GetUserId(aircraft);
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var pilotLogin = await api.LoginFullAsync(pilot, pp);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
var req = new
{
missionId = "M-2026-05-14-001",
aircraftId = aircraftId,
plannedDurationH = 8.0,
requestedScope = new[] { "submit_telemetry", "fetch_mission" },
};
// Act
using var resp = await client.PostAsJsonAsync("/sessions/mission", req);
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<MissionResponse>();
body.Should().NotBeNull();
// Assert — claims
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(body!.AccessToken);
jwt.Claims.Should().Contain(c => c.Type == "mission_id" && c.Value == "M-2026-05-14-001");
jwt.Claims.Should().Contain(c => c.Type == "aircraft_id" && c.Value == aircraftId.ToString());
jwt.Claims.Should().Contain(c => c.Type == "token_class" && c.Value == "mission");
jwt.Audiences.Should().Contain("satellite-provider");
// Lifetime: 8 h planned + 1 h buffer = 9 h ±60 s
var driftSeconds = (jwt.ValidTo - DateTime.UtcNow.AddHours(9)).TotalSeconds;
Math.Abs(driftSeconds).Should().BeLessThan(60);
// Sessions row class=mission, aircraft_id set
(await _fixture.Db.CountActiveSessionsForUser(pilot, "mission")).Should().Be(1);
(await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(1);
}
finally
{
await CleanupUser(pilot);
await CleanupUser(aircraft);
}
}
[Fact]
public async Task AC2_Mission_id_must_match_pattern()
{
var (pilot, pp) = await SeedUser("p2", role: 40);
var (aircraft, _) = await SeedUser("a2", role: 30);
try
{
var aircraftId = await GetUserId(aircraft);
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var pilotLogin = await api.LoginFullAsync(pilot, pp);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
var req = new
{
missionId = "not-a-mission-id",
aircraftId,
plannedDurationH = 1.0,
requestedScope = (string[])[]
};
using var resp = await client.PostAsJsonAsync("/sessions/mission", req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
finally
{
await CleanupUser(pilot);
await CleanupUser(aircraft);
}
}
[Theory]
[InlineData(0.05)] // < 6 min
[InlineData(13.0)] // > 12 h cap
public async Task AC2_Planned_duration_must_be_within_bounds(double hours)
{
var (pilot, pp) = await SeedUser("p3", role: 40);
var (aircraft, _) = await SeedUser("a3", role: 30);
try
{
var aircraftId = await GetUserId(aircraft);
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var pilotLogin = await api.LoginFullAsync(pilot, pp);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
var req = new
{
missionId = "M-2026-05-14-002",
aircraftId,
plannedDurationH = hours,
};
using var resp = await client.PostAsJsonAsync("/sessions/mission", req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
finally
{
await CleanupUser(pilot);
await CleanupUser(aircraft);
}
}
[Fact]
public async Task AC3_Aircraft_must_exist_with_companionpc_role()
{
var (pilot, pp) = await SeedUser("p4", role: 40);
var (notAircraft, _) = await SeedUser("a4", role: 10); // ResourceUploader, not CompanionPC
try
{
var bogusId = await GetUserId(notAircraft);
using var client = _fixture.CreateHttpClient();
var api = new ApiClient(client);
var pilotLogin = await api.LoginFullAsync(pilot, pp);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
var req = new
{
missionId = "M-2026-05-14-003",
aircraftId = bogusId,
plannedDurationH = 1.0,
};
using var resp = await client.PostAsJsonAsync("/sessions/mission", req);
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
finally
{
await CleanupUser(pilot);
await CleanupUser(notAircraft);
}
}
[Fact]
public async Task AC4_Aircraft_login_auto_revokes_open_mission_sessions()
{
var (pilot, pp) = await SeedUser("p5", role: 40);
var (aircraft, ap) = await SeedUser("a5", role: 30);
try
{
var aircraftId = await GetUserId(aircraft);
// Pilot mints a mission token for the aircraft
using var pilotClient = _fixture.CreateHttpClient();
var pilotApi = new ApiClient(pilotClient);
var pilotLogin = await pilotApi.LoginFullAsync(pilot, pp);
pilotClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pilotLogin.AccessToken);
using var mintResp = await pilotClient.PostAsJsonAsync("/sessions/mission", new
{
missionId = "M-2026-05-14-004",
aircraftId = aircraftId,
plannedDurationH = 6.0,
});
mintResp.StatusCode.Should().Be(HttpStatusCode.OK);
(await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(1);
// Act — aircraft reconnects: logs in as itself
using var aircraftClient = _fixture.CreateHttpClient();
var aircraftApi = new ApiClient(aircraftClient);
await aircraftApi.LoginFullAsync(aircraft, ap);
// Assert — mission session for this aircraft is gone (post-flight reconnect)
(await _fixture.Db.CountOpenMissionsForAircraft(aircraftId)).Should().Be(0);
}
finally
{
await CleanupUser(pilot);
await CleanupUser(aircraft);
}
}
private sealed class MissionResponse
{
public string AccessToken { get; init; } = "";
public DateTime AccessExp { get; init; }
public string TokenClass { get; init; } = "";
public Guid SessionId { get; init; }
}
}
+1
View File
@@ -9,4 +9,5 @@ psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/04_detection_
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/06_users_email_unique.sql" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/06_users_email_unique.sql"
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/07_auth_lockout_and_audit.sql" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/07_auth_lockout_and_audit.sql"
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/08_sessions.sql" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/08_sessions.sql"
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f "$SQL_DIR/09_sessions_logout_and_mission.sql"
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f /opt/test-seed.sql psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d azaion -f /opt/test-seed.sql
+37
View File
@@ -0,0 +1,37 @@
-- AZ-535 + AZ-533 (Epic AZ-529): logout/revocation surface + mission tokens.
--
-- AZ-535 adds `revoked_by_user_id` so admin / system revocations can be audited.
-- AZ-533 adds `class` and `aircraft_id` so mission tokens (long-lived, no refresh,
-- bound to a specific aircraft) live in the same row store as interactive sessions
-- and the auto-revoke-on-reconnect middleware can index them cheaply.
--
-- The two columns ride together because mission token revocation reuses the same
-- code path the logout endpoints exercise; splitting them into separate migrations
-- would just force duplicate ALTER TABLE round-trips in the test bootstrap.
ALTER TABLE public.sessions
ADD COLUMN IF NOT EXISTS revoked_by_user_id uuid NULL REFERENCES public.users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS class varchar(32) NOT NULL DEFAULT 'interactive',
ADD COLUMN IF NOT EXISTS aircraft_id uuid NULL REFERENCES public.users(id) ON DELETE SET NULL,
-- AZ-533: mission tokens have no refresh value, so refresh_hash is now nullable.
-- Interactive rows continue to set it; the unique index already ignores nulls
-- by default in Postgres.
ALTER COLUMN refresh_hash DROP NOT NULL;
-- AZ-533: cheap "find every mission session belonging to UAV-X that hasn't been
-- revoked yet" — driven on every successful auth call so it must be O(active rows
-- for that aircraft), not O(all sessions). Partial index excludes the cold pile of
-- already-revoked rows.
CREATE INDEX IF NOT EXISTS sessions_aircraft_active_idx
ON public.sessions (aircraft_id, class)
WHERE revoked_at IS NULL AND aircraft_id IS NOT NULL;
-- AZ-535: snapshot endpoint pulls every revocation since <ts>; index on revoked_at
-- makes the "since" filter O(matching rows) instead of full-table scan.
CREATE INDEX IF NOT EXISTS sessions_revoked_at_idx
ON public.sessions (revoked_at)
WHERE revoked_at IS NOT NULL;
-- AZ-535: session-revoke and snapshot endpoints both write/read these columns;
-- existing grants in 08_sessions.sql already cover the table, so no new grants
-- needed.