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;
///
/// AZ-533 — issues long-lived single-use access tokens for offline UAV missions.
/// Distinct from because:
///
/// - Lifetime is per-mission (≤ 12 h), not per-session policy.
/// - Audience is narrowed to satellite-provider, not the broad admin audience.
/// - No refresh: a single token covers the entire flight, then dies.
/// - Carries mission-specific claims (mission_id, aircraft_id, valid_region).
///
///
public interface IMissionTokenService
{
Task Issue(Guid pilotUserId, MissionSessionRequest request, CancellationToken ct = default);
}
public class MissionTokenService(
IDbFactory dbFactory,
IJwtSigningKeyProvider signingKeys,
IOptions 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 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
{
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);
}
}