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); } }