[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
{
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status409Conflict
ExceptionEnum.AccountLocked => StatusCodes.Status423Locked,
ExceptionEnum.LoginRateLimited => StatusCodes.Status429TooManyRequests,
ExceptionEnum.InvalidRefreshToken => StatusCodes.Status401Unauthorized,
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 Azaion.Common;
using Azaion.Common.Configs;
@@ -85,9 +86,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
var apiAdminPolicy = new AuthorizationPolicyBuilder()
.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 =>
{
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
o.AddPolicy(nameof(apiAdminPolicy), apiAdminPolicy);
o.AddPolicy(nameof(revocationReaderPolicy), revocationReaderPolicy);
});
#endregion Policies
@@ -131,6 +139,8 @@ builder.Services.AddSingleton<IJwtSigningKeyProvider>(jwtSigningKeyProvider);
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IRefreshTokenService, RefreshTokenService>();
builder.Services.AddScoped<ISessionService, SessionService>();
builder.Services.AddScoped<IMissionTokenService, MissionTokenService>();
builder.Services.AddScoped<IResourcesService, ResourcesService>();
builder.Services.AddScoped<IDetectionClassService, DetectionClassService>();
builder.Services.AddScoped<IAuditLog, AuditLog>();
@@ -258,11 +268,18 @@ app.MapPost("/login",
IUserService userService,
IAuthService authService,
IRefreshTokenService refreshTokens,
ISessionService sessionService,
CancellationToken cancellationToken) =>
{
var user = await userService.ValidateUser(request, ct: cancellationToken);
var (refreshToken, session) = await refreshTokens.IssueForNewLogin(user.Id, cancellationToken);
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
{
AccessToken = access.Jwt,
@@ -281,12 +298,18 @@ app.MapPost("/token/refresh",
IRefreshTokenService refreshTokens,
IUserService userService,
IAuthService authService,
ISessionService sessionService,
CancellationToken cancellationToken) =>
{
var (newRefresh, session) = await refreshTokens.Rotate(request.RefreshToken, cancellationToken);
var user = await userService.GetById(session.UserId, cancellationToken);
if (user == null) throw new BusinessException(ExceptionEnum.InvalidRefreshToken);
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
{
AccessToken = access.Jwt,
@@ -298,6 +321,89 @@ app.MapPost("/token/refresh",
.AllowAnonymous()
.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).
app.MapGet("/.well-known/jwks.json",
(IJwtSigningKeyProvider keys, HttpContext http) =>