diff --git a/Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj b/Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj
new file mode 100644
index 0000000..a633c7b
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/ADS.Apis.Gps.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Apis/ADS.Apis.Gps/ADS.Apis.Gps.http b/Apis/ADS.Apis.Gps/ADS.Apis.Gps.http
new file mode 100644
index 0000000..6c806bb
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/ADS.Apis.Gps.http
@@ -0,0 +1,6 @@
+@ADS.Apis.Gps_HostAddress = http://localhost:5286
+
+GET {{ADS.Apis.Gps_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsClientCredentialValidator.cs b/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsClientCredentialValidator.cs
new file mode 100644
index 0000000..28652dc
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsClientCredentialValidator.cs
@@ -0,0 +1,13 @@
+namespace ADS.Apis.Gps.Application.Abstractions.Security;
+
+public interface IAdsClientCredentialValidator
+{
+ Task ValidateAsync(string clientId, string clientSecret, CancellationToken ct = default);
+}
+
+public sealed record AdsClientCredentialResult(
+ bool IsValid,
+ int? ClientId,
+ string? ClientKey,
+ string? ClientName
+);
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsUserCredentialValidator.cs b/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsUserCredentialValidator.cs
new file mode 100644
index 0000000..3b57a2b
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Abstractions/Security/IAdsUserCredentialValidator.cs
@@ -0,0 +1,13 @@
+namespace ADS.Apis.Gps.Application.Abstractions.Security;
+
+public interface IAdsUserCredentialValidator
+{
+ Task ValidateAsync(string username, string password, CancellationToken ct = default);
+}
+
+public sealed record AdsUserCredentialResult(
+ bool IsValid,
+ int? UserId,
+ string? Username,
+ string? DisplayName
+);
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Common/Crypto/ILegacyPasswordEncryptor.cs b/Apis/ADS.Apis.Gps/Application/Common/Crypto/ILegacyPasswordEncryptor.cs
new file mode 100644
index 0000000..b27b784
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Common/Crypto/ILegacyPasswordEncryptor.cs
@@ -0,0 +1,6 @@
+namespace ADS.Apis.Gps.Application.Common.Crypto;
+
+public interface ILegacyPasswordEncryptor
+{
+ string Encrypt(string plainText);
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs b/Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs
new file mode 100644
index 0000000..0b1ad66
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Common/Crypto/Md5Hex32.cs
@@ -0,0 +1,38 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ADS.Apis.Gps.Application.Common.Crypto;
+
+public static class Md5Hex32
+{
+ public static bool IsMd5Hex32(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length != 32) return false;
+
+ for (var i = 0; i < value.Length; i++)
+ {
+ var c = value[i];
+ var isHex =
+ (c >= '0' && c <= '9') ||
+ (c >= 'a' && c <= 'f') ||
+ (c >= 'A' && c <= 'F');
+
+ if (!isHex) return false;
+ }
+
+ return true;
+ }
+
+ public static string ToMd5Hex32(string plainText)
+ {
+ using var md5 = MD5.Create();
+ var bytes = Encoding.UTF8.GetBytes(plainText ?? string.Empty);
+ var hash = md5.ComputeHash(bytes);
+
+ var sb = new StringBuilder(32);
+ foreach (var b in hash)
+ sb.Append(b.ToString("x2"));
+
+ return sb.ToString();
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Contracts/Auth/ISicrepAuthService.cs b/Apis/ADS.Apis.Gps/Application/Contracts/Auth/ISicrepAuthService.cs
new file mode 100644
index 0000000..1db5fc2
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Contracts/Auth/ISicrepAuthService.cs
@@ -0,0 +1,10 @@
+using System.Threading;
+using System.Threading.Tasks;
+using ADS.Apis.Gps.Application.Dtos.Auth;
+
+namespace ADS.Apis.Gps.Application.Contracts.Auth;
+
+public interface ISicrepAuthService
+{
+ Task CreateTokenAsync(SicrepAuthRequest request, string? ip, string? mac, string? grupoEmpresa, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Contracts/Common/ApiResponse.cs b/Apis/ADS.Apis.Gps/Application/Contracts/Common/ApiResponse.cs
new file mode 100644
index 0000000..2c87706
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Contracts/Common/ApiResponse.cs
@@ -0,0 +1,20 @@
+namespace ADS.Apis.Gps.Contracts.Common;
+
+public sealed class ApiResponse
+{
+ public int CountRecords { get; init; }
+ public int StatusCode { get; init; }
+ public string StatusCodeText { get; init; } = string.Empty;
+
+ public string? ConsoleMessage { get; init; } // mensaje consola
+ public UiMessage? ScreenMessage { get; init; } // mensaje pantalla
+
+ public T? Value { get; init; }
+}
+
+public sealed class UiMessage
+{
+ public int MessageType { get; init; } // tipoMensaje
+ public string Message { get; init; } = ""; // mensaje
+ public int MessageCode { get; init; } // codigoMsg
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs b/Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs
new file mode 100644
index 0000000..001b668
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Contracts/Common/PagedQuery.cs
@@ -0,0 +1,11 @@
+namespace ADS.Apis.Gps.Contracts.Common;
+
+public sealed class PagedQuery
+{
+ public string? Search { get; init; }
+ public int Page { get; init; } = 1;
+ public int PageSize { get; init; } = 50;
+
+ // Si Pagination=false, tu servicio puede ignorar Page/PageSize y devolver todo (o un límite fijo).
+ public bool Pagination { get; init; } = true;
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs b/Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs
new file mode 100644
index 0000000..67e2318
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Contracts/Common/RequestOf.cs
@@ -0,0 +1,12 @@
+namespace ADS.Apis.Gps.Contracts.Common;
+
+public sealed class RequestOf
+{
+ public T? Value { get; init; }
+
+ // Si también quieres paginación en algunos body requests:
+ public string? Search { get; init; }
+ public int Page { get; init; } = 1;
+ public int PageSize { get; init; } = 50;
+ public bool Pagination { get; init; } = true;
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs b/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs
new file mode 100644
index 0000000..f7237b9
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthRequest.cs
@@ -0,0 +1,10 @@
+namespace ADS.Apis.Gps.Application.Dtos.Auth;
+
+public sealed class SicrepAuthRequest
+{
+ public string Usuario { get; set; } = string.Empty;
+ public string Contrasena { get; set; } = string.Empty;
+
+ // Opcional: si quieres forzar si viene en MD5 o plaintext
+ // public bool IsMd5Hex32 { get; set; }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthResponse.cs b/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthResponse.cs
new file mode 100644
index 0000000..423286f
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Dtos/Auth/SicrepAuthResponse.cs
@@ -0,0 +1,7 @@
+namespace ADS.Apis.Gps.Application.Dtos.Auth;
+
+public sealed class SicrepAuthResponse
+{
+ public string AccessToken { get; init; } = string.Empty;
+ public string TokenType { get; init; } = "Bearer";
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Units/IUnitLocationQueryService.cs b/Apis/ADS.Apis.Gps/Application/Units/IUnitLocationQueryService.cs
new file mode 100644
index 0000000..2287602
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Units/IUnitLocationQueryService.cs
@@ -0,0 +1,13 @@
+namespace ADS.Apis.Gps.Application.Units;
+
+public interface IUnitLocationQueryService
+{
+ Task GetLastLocationByNoEconomicoAsync(
+ int cveUsuario,
+ string noEconomico,
+ CancellationToken ct = default);
+ Task> GetLocationsByClientAsync(
+ int cveUsuario,
+ int cveCliente,
+ CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Units/UnitClientLocationResponse.cs b/Apis/ADS.Apis.Gps/Application/Units/UnitClientLocationResponse.cs
new file mode 100644
index 0000000..e1963a1
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Units/UnitClientLocationResponse.cs
@@ -0,0 +1,12 @@
+namespace ADS.Apis.Gps.Application.Units;
+
+public sealed class UnitClientLocationResponse
+{
+ public string? NoEconomico { get; set; }
+ public decimal? Velocidad { get; set; }
+ public bool? Encendido { get; set; }
+ public DateTime? FechaTrama { get; set; }
+ public string? Punto { get; set; }
+ public double? Longitud { get; set; }
+ public double? Latitud { get; set; }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/Units/UnitLastLocationResponse.cs b/Apis/ADS.Apis.Gps/Application/Units/UnitLastLocationResponse.cs
new file mode 100644
index 0000000..6e96d7b
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/Units/UnitLastLocationResponse.cs
@@ -0,0 +1,12 @@
+namespace ADS.Apis.Gps.Application.Units;
+
+public sealed class UnitLastLocationResponse
+{
+ public string? NoEconomico { get; set; }
+ public decimal? Velocidad { get; set; }
+ public bool? Encendido { get; set; }
+ public DateTime? FechaTrama { get; set; }
+ public string? Punto { get; set; }
+ public double? Longitud { get; set; }
+ public double? Latitud { get; set; }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Application/UseCases/Auth/SicrepAuthService.cs b/Apis/ADS.Apis.Gps/Application/UseCases/Auth/SicrepAuthService.cs
new file mode 100644
index 0000000..4900276
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Application/UseCases/Auth/SicrepAuthService.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using ADS.Apis.Gps.Application.Common.Crypto;
+using ADS.Apis.Gps.Application.Contracts.Auth;
+using ADS.Apis.Gps.Application.Dtos.Auth;
+using ADS.Apis.Gps.Domain.Ports.Sicrep;
+using ADS.Helpers.MultiTenant.Abstractions;
+
+// AJUSTA el namespace real de IAdsTokenService
+using ADS.Helpers.Auth.Abstractions;
+using ADS.Helpers.MultiTenant.Context;
+
+namespace ADS.Apis.Gps.Application.UseCases.Auth;
+
+public sealed class SicrepAuthService : ISicrepAuthService
+{
+ private readonly ISicrepUserValidationRepository _repo;
+ private readonly ITenantContextAccessor _tenantAccessor;
+ private readonly IAdsTokenService _tokenService;
+ private readonly ILegacyPasswordEncryptor _passwordEncryptor;
+
+ public SicrepAuthService(
+ ISicrepUserValidationRepository repo,
+ ITenantContextAccessor tenantAccessor,
+ IAdsTokenService tokenService,
+ ILegacyPasswordEncryptor passwordEncryptor)
+ {
+ _repo = repo;
+ _tenantAccessor = tenantAccessor;
+ _tokenService = tokenService;
+ _passwordEncryptor = passwordEncryptor;
+ }
+
+ public async Task CreateTokenAsync(SicrepAuthRequest request,
+ string? ip,
+ string? mac,
+ string? grupoEmpresa, CancellationToken ct = default)
+ {
+ if (request is null) throw new ArgumentNullException(nameof(request));
+
+ var tenant = _tenantAccessor.Current;
+ if (tenant is null)
+ throw new UnauthorizedAccessException("TenantContext not available. Ensure UseInternalTenantDb() is configured.");
+
+ var cveEmpresa = tenant.EmpresaId;
+ var cveCliente = tenant.ClienteId;
+ var tenantId = tenant.TenantId;
+
+ if (string.IsNullOrWhiteSpace(request.Usuario))
+ throw new ArgumentException("Usuario is required.", nameof(request.Usuario));
+
+ if (string.IsNullOrWhiteSpace(request.Contrasena))
+ throw new ArgumentException("Contrasena is required.", nameof(request.Contrasena));
+
+ // NOTA MD5:
+ // No puedo asegurar si el SP espera MD5 hex 32 o plaintext.
+ // Estrategia segura:
+ // - Si ya viene con 32 hex -> se manda tal cual.
+ // - Si no -> se manda MD5.
+ // Si tu sistema espera plaintext, cambia esta línea.
+ var contrasena32 = _passwordEncryptor.Encrypt(request.Contrasena);/*Md5Hex32.IsMd5Hex32(request.Contrasena)
+ ? request.Contrasena
+ : Md5Hex32.ToMd5Hex32(request.Contrasena);*/
+
+ var result = await _repo.ValidateUserAsync(request.Usuario, contrasena32, cveEmpresa,ip,mac,grupoEmpresa, ct);
+
+ if (result is null || result.CveUsuario <= 0 || result.Acceso != 1)
+ throw new UnauthorizedAccessException("Invalid credentials or access denied.");
+
+ var claims = new List
+ {
+ new("CveUsuario", result.CveUsuario.ToString()),
+ new("CveEmpresa", cveEmpresa.ToString()),
+ new("CveCliente", cveCliente.ToString())
+ };
+
+ if (result.CveRol is not null)
+ claims.Add(new("CveRol", result.CveRol.Value.ToString()));
+
+ var token = await _tokenService.IssueFromClaimsAsync(claims, scope: null, ct);
+
+ return new SicrepAuthResponse
+ {
+ AccessToken = token.AccessToken
+ };
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs b/Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs
new file mode 100644
index 0000000..13968b1
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Controllers/Sicrep/SicrepAuthController.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using ADS.Apis.Gps.Application.Contracts.Auth;
+using ADS.Apis.Gps.Application.Dtos.Auth;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ADS.Apis.Gps.Controllers.Sicrep;
+
+[ApiController]
+[Route("gps/sicrep/auth")]
+public sealed class SicrepAuthController : ControllerBase
+{
+ private readonly ISicrepAuthService _service;
+
+ public SicrepAuthController(ISicrepAuthService service)
+ {
+ _service = service;
+ }
+
+ [HttpPost("token")]
+ public async Task> CreateToken(
+ [FromBody] SicrepAuthRequest request,
+ CancellationToken ct)
+ {
+ var ip = GetClientIp(HttpContext);
+ var mac = HttpContext.Request.Headers["X-Device-Mac"].ToString(); // si tu app lo manda
+ var grupoEmpresa = HttpContext.Request.Headers["X-GrupoEmpresa"].ToString();
+ var response = await _service.CreateTokenAsync(request,ip,mac,grupoEmpresa, ct);
+ return Ok(response);
+ }
+ static string? GetClientIp(HttpContext http)
+ {
+ // 1) Si hay proxy, X-Forwarded-For trae una lista: "client, proxy1, proxy2"
+ if (http.Request.Headers.TryGetValue("X-Forwarded-For", out var xff))
+ {
+ var first = xff.ToString().Split(',')[0].Trim();
+ if (!string.IsNullOrWhiteSpace(first)) return first;
+ }
+
+ // 2) fallback
+ return http.Connection.RemoteIpAddress?.ToString();
+ }
+ // // ========= UNITS =========
+
+ // [HttpGet("units")]
+ // [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ // public Task> GetUnits(
+ // [FromQuery] string? search,
+ // [FromQuery] int page = 1,
+ // [FromQuery] int pageSize = 50,
+ // CancellationToken ct = default)
+ // => _svc.GetUnitsAsync(search, page, pageSize, ct);
+
+ // [HttpGet("units/{unitId:int}")]
+ // [ProducesResponseType(typeof(UnitDetail), StatusCodes.Status200OK)]
+ // [ProducesResponseType(StatusCodes.Status404NotFound)]
+ // public async Task> GetUnit([FromRoute] int unitId, CancellationToken ct = default)
+ // {
+ // var dto = await _svc.GetUnitAsync(unitId, ct);
+ // return dto is null ? NotFound() : Ok(dto);
+ // }
+
+ // [HttpGet("units/{unitId:int}/location")]
+ // [ProducesResponseType(typeof(UnitLocation), StatusCodes.Status200OK)]
+ // [ProducesResponseType(StatusCodes.Status404NotFound)]
+ // public async Task> GetLastLocation([FromRoute] int unitId, CancellationToken ct = default)
+ // {
+ // var dto = await _svc.GetLastLocationAsync(unitId, ct);
+ // return dto is null ? NotFound() : Ok(dto);
+ // }
+
+ // [HttpGet("units/{unitId:int}/positions")]
+ // [ProducesResponseType(typeof(PositionsResponse), StatusCodes.Status200OK)]
+ // public Task GetPositions(
+ // [FromRoute] int unitId,
+ // [FromQuery] DateTime from,
+ // [FromQuery] DateTime to,
+ // [FromQuery] int limit = 5000,
+ // CancellationToken ct = default)
+ // => _svc.GetPositionsAsync(unitId, from, to, limit, ct);
+
+ // [HttpGet("units/{unitId:int}/events")]
+ // [ProducesResponseType(typeof(EventsResponse), StatusCodes.Status200OK)]
+ // public Task GetEvents(
+ // [FromRoute] int unitId,
+ // [FromQuery] DateTime from,
+ // [FromQuery] DateTime to,
+ // CancellationToken ct = default)
+ // => _svc.GetEventsAsync(unitId, from, to, ct);
+
+ // [HttpGet("units/{unitId:int}/status")]
+ // [ProducesResponseType(typeof(UnitStatus), StatusCodes.Status200OK)]
+ // [ProducesResponseType(StatusCodes.Status404NotFound)]
+ // public async Task> GetStatus([FromRoute] int unitId, CancellationToken ct = default)
+ // {
+ // var dto = await _svc.GetStatusAsync(unitId, ct);
+ // return dto is null ? NotFound() : Ok(dto);
+ // }
+
+ // [HttpPost("units/{unitId:int}/status")]
+ // [ProducesResponseType(typeof(UnitStatus), StatusCodes.Status200OK)]
+ // public Task SetStatus(
+ // [FromRoute] int unitId,
+ // [FromBody] UnitStatusRequest body,
+ // CancellationToken ct = default)
+ // => _svc.SetStatusAsync(unitId, body, ct);
+
+ // [HttpGet("units/{unitId:int}/health")]
+ // [ProducesResponseType(typeof(UnitHealth), StatusCodes.Status200OK)]
+ // [ProducesResponseType(StatusCodes.Status404NotFound)]
+ // public async Task> GetHealth([FromRoute] int unitId, CancellationToken ct = default)
+ // {
+ // var dto = await _svc.GetHealthAsync(unitId, ct);
+ // return dto is null ? NotFound() : Ok(dto);
+ // }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Controllers/UnitsController.cs b/Apis/ADS.Apis.Gps/Controllers/UnitsController.cs
new file mode 100644
index 0000000..3182fad
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Controllers/UnitsController.cs
@@ -0,0 +1,57 @@
+using ADS.Apis.Gps.Application.Units;
+using ADS.Helpers.Middleware.InternalUser;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ADS.Apis.Gps.Controllers;
+
+[ApiController]
+[Route("units")]
+public sealed class UnitsController : ControllerBase
+{
+ private readonly IUnitLocationQueryService _unitLocationQueryService;
+ private readonly IInternalUserContextAccessor _internalUserContextAccessor;
+
+ public UnitsController(
+ IUnitLocationQueryService unitLocationQueryService,
+ IInternalUserContextAccessor internalUserContextAccessor)
+ {
+ _unitLocationQueryService = unitLocationQueryService;
+ _internalUserContextAccessor = internalUserContextAccessor;
+ }
+
+ [HttpGet("location/{noEconomico}")]
+ public async Task GetLastLocationByNoEconomico(
+ [FromRoute] string noEconomico,
+ CancellationToken ct)
+ {
+ var cveUsuario = _internalUserContextAccessor.Current?.CveUser;
+
+ if (cveUsuario is null || cveUsuario <= 0)
+ return Unauthorized("No se encontró el usuario interno.");
+
+ var result = await _unitLocationQueryService
+ .GetLastLocationByNoEconomicoAsync(cveUsuario.Value, noEconomico, ct);
+
+ if (result is null)
+ return NotFound();
+
+ return Ok(result);
+ }
+
+ [HttpGet("client/{cveCliente}")]
+ public async Task GetLocationsByClient(
+ [FromRoute] int cveCliente,
+ CancellationToken ct)
+ {
+ var cveUsuario = _internalUserContextAccessor.Current?.CveUser;
+
+ if (cveUsuario is null || cveUsuario <= 0)
+ return Unauthorized("No se encontró el usuario interno.");
+
+ var result = await _unitLocationQueryService
+ .GetLocationsByClientAsync(cveUsuario.Value, cveCliente, ct);
+
+ return Ok(result);
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Domain/Models/Sicrep/SicrepUserValidationResult.cs b/Apis/ADS.Apis.Gps/Domain/Models/Sicrep/SicrepUserValidationResult.cs
new file mode 100644
index 0000000..f1ed300
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Domain/Models/Sicrep/SicrepUserValidationResult.cs
@@ -0,0 +1,11 @@
+namespace ADS.Apis.Gps.Domain.Models.Sicrep;
+
+public sealed class SicrepUserValidationResult
+{
+ public int CveUsuario { get; init; }
+ public int Acceso { get; init; }
+
+ public int? CveRol { get; init; }
+
+ // Si tu SP regresa más campos, agrégalos aquí (p.ej. nombre, email, etc.)
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Domain/Ports/Sicrep/ISicrepUserValidationRepository.cs b/Apis/ADS.Apis.Gps/Domain/Ports/Sicrep/ISicrepUserValidationRepository.cs
new file mode 100644
index 0000000..0adf804
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Domain/Ports/Sicrep/ISicrepUserValidationRepository.cs
@@ -0,0 +1,17 @@
+using System.Threading;
+using System.Threading.Tasks;
+using ADS.Apis.Gps.Domain.Models.Sicrep;
+
+namespace ADS.Apis.Gps.Domain.Ports.Sicrep;
+
+public interface ISicrepUserValidationRepository
+{
+ Task ValidateUserAsync(
+ string usuario,
+ string contrasena32,
+ int cveEmpresa,
+ string? ip,
+ string? mac,
+ string? grupoEmpresa,
+ CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyCryptoOptions.cs b/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyCryptoOptions.cs
new file mode 100644
index 0000000..86b7a68
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyCryptoOptions.cs
@@ -0,0 +1,8 @@
+namespace ADS.Apis.Gps.Infrastructure.Crypto;
+
+public sealed class LegacyCryptoOptions
+{
+ public string Mode { get; set; } = "AES"; // "DES" o "AES"
+ public string Key { get; set; } = "";
+ public string IV { get; set; } = ""; // Base64
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyPasswordEncryptor.cs b/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyPasswordEncryptor.cs
new file mode 100644
index 0000000..ff3dcea
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Infrastructure/Crypto/LegacyPasswordEncryptor.cs
@@ -0,0 +1,69 @@
+using System.Security.Cryptography;
+using System.Text;
+using ADS.Apis.Gps.Application.Common.Crypto;
+using Microsoft.Extensions.Options;
+
+namespace ADS.Apis.Gps.Infrastructure.Crypto;
+
+public sealed class LegacyPasswordEncryptor : ILegacyPasswordEncryptor
+{
+ private readonly LegacyCryptoOptions _opt;
+
+ public LegacyPasswordEncryptor(IOptions opt)
+ => _opt = opt.Value;
+
+ public string Encrypt(string plainText)
+ {
+ if (string.IsNullOrEmpty(plainText)) return string.Empty;
+
+ var mode = (_opt.Mode ?? "AES").Trim().ToUpperInvariant();
+ var iv = Convert.FromBase64String(_opt.IV ?? "");
+
+ return mode switch
+ {
+ "DES" => EncryptDesBase64(plainText, _opt.Key, iv),
+ "AES" => EncryptAesBase64(plainText, _opt.Key, iv),
+ _ => throw new InvalidOperationException($"Unsupported LegacyCrypto.Mode: {_opt.Mode}")
+ };
+ }
+
+ private static string EncryptDesBase64(string plainText, string keySource, byte[] iv8)
+ {
+ if (iv8.Length != 8) throw new ArgumentException("DES IV must be 8 bytes.", nameof(iv8));
+
+ var key8 = Encoding.UTF8.GetBytes((keySource ?? string.Empty).PadRight(8).Substring(0, 8));
+
+ using var des = new DESCryptoServiceProvider
+ {
+ Mode = CipherMode.CBC,
+ Padding = PaddingMode.PKCS7,
+ Key = key8,
+ IV = iv8
+ };
+
+ var input = Encoding.UTF8.GetBytes(plainText);
+ using var encryptor = des.CreateEncryptor(des.Key, des.IV);
+ var cipher = encryptor.TransformFinalBlock(input, 0, input.Length);
+
+ return Convert.ToBase64String(cipher);
+ }
+
+ private static string EncryptAesBase64(string plainText, string keySource, byte[] iv16)
+ {
+ if (iv16.Length != 16) throw new ArgumentException("AES IV must be 16 bytes.", nameof(iv16));
+
+ var key32 = Encoding.UTF8.GetBytes((keySource ?? string.Empty).PadRight(32).Substring(0, 32));
+
+ using var aes = Aes.Create();
+ aes.Mode = CipherMode.CBC;
+ aes.Padding = PaddingMode.PKCS7;
+ aes.Key = key32;
+ aes.IV = iv16;
+
+ var input = Encoding.UTF8.GetBytes(plainText);
+ using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
+ var cipher = encryptor.TransformFinalBlock(input, 0, input.Length);
+
+ return Convert.ToBase64String(cipher);
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Data/Dapper/DapperSicrepUserValidationRepository.cs b/Apis/ADS.Apis.Gps/Infrastructure/Data/Dapper/DapperSicrepUserValidationRepository.cs
new file mode 100644
index 0000000..57e39f7
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Infrastructure/Data/Dapper/DapperSicrepUserValidationRepository.cs
@@ -0,0 +1,57 @@
+using System.Data;
+using System.Threading;
+using System.Threading.Tasks;
+using ADS.Apis.Gps.Domain.Models.Sicrep;
+using ADS.Apis.Gps.Domain.Ports.Sicrep;
+using ADS.Helpers.Data.Abstractions;
+using ADS.Helpers.MultiTenant.Abstractions;
+using ADS.Helpers.MultiTenant.Context;
+using Dapper;
+
+namespace ADS.Apis.Gps.Infrastructure.Data.Dapper;
+
+public sealed class DapperSicrepUserValidationRepository : ISicrepUserValidationRepository
+{
+ private readonly ITenantDbConnectionFactory _connFactory;
+ private readonly ITenantContextAccessor _tenantAccessor;
+
+ public DapperSicrepUserValidationRepository(
+ ITenantDbConnectionFactory connFactory,
+ ITenantContextAccessor tenantAccessor)
+ {
+ _connFactory = connFactory;
+ _tenantAccessor = tenantAccessor;
+ }
+
+ public async Task ValidateUserAsync(
+ string usuario,
+ string contrasena32,
+ int cveEmpresa,
+ string? ip,
+ string? mac,
+ string? grupoEmpresa,
+ CancellationToken ct = default)
+ {
+ var tenant = _tenantAccessor.Current
+ ?? throw new UnauthorizedAccessException("TenantContext not available.");
+
+ // USAMOS TU MÉTODO REAL
+ using var conn = _connFactory.Create();//tenant.Database.ConnectionString);
+
+ var p = new DynamicParameters();
+ p.Add("@Usuario", usuario, DbType.String, size: 32);
+ p.Add("@Contraseña", contrasena32, DbType.String, size: 32);
+ p.Add("@CveEmpresa", cveEmpresa, DbType.Int32);
+ p.Add("@IP", ip ?? string.Empty, DbType.String, size: 20);
+ p.Add("@MAC", mac ?? string.Empty, DbType.String, size: 50);
+ p.Add("@GrupoEmpresa", grupoEmpresa ?? string.Empty, DbType.String, size: 100);
+ const string sp = "[dbo].[PA_Sis_UsuarioValidacionGet]";
+
+ var result = await conn.QueryFirstOrDefaultAsync(
+ sp,
+ p,
+ commandType: CommandType.StoredProcedure);
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/Apis/ADS.Apis.Gps/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..04d3734
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs
@@ -0,0 +1,34 @@
+using ADS.Apis.Gps.Application.Abstractions.Security;
+using ADS.Apis.Gps.Application.Common.Crypto;
+using ADS.Apis.Gps.Application.Contracts.Auth;
+using ADS.Apis.Gps.Application.UseCases.Auth;
+using ADS.Apis.Gps.Domain.Ports.Sicrep;
+using ADS.Apis.Gps.Infrastructure.Crypto;
+using ADS.Apis.Gps.Infrastructure.Data.Dapper;
+using ADS.Apis.Gps.Infrastructure.Security;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ADS.Apis.Gps.Infrastructure.DependencyInjection;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddGpsSicrepAuth(this IServiceCollection services)
+ {
+ services.AddScoped();
+ services.AddScoped();
+ // Domain Ports -> Infrastructure
+ services.AddScoped();
+
+ // Application
+ services.AddScoped();
+
+ return services;
+ }
+ public static IServiceCollection AddGpsInfrastructure(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection("LegacyCrypto"));
+ services.AddSingleton();
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsClientCredentialValidator.cs b/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsClientCredentialValidator.cs
new file mode 100644
index 0000000..8e906fa
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsClientCredentialValidator.cs
@@ -0,0 +1,45 @@
+using System.Data;
+using Dapper;
+using ADS.Apis.Gps.Application.Abstractions.Security;
+using ADS.Helpers.Data.Abstractions;
+
+namespace ADS.Apis.Gps.Infrastructure.Security;
+
+public sealed class AdsClientCredentialValidator : IAdsClientCredentialValidator
+{
+ private readonly ITenantDbConnectionFactory _db;
+
+ public AdsClientCredentialValidator(ITenantDbConnectionFactory db)
+ {
+ _db = db;
+ }
+
+ public async Task ValidateAsync(string clientId, string clientSecret, CancellationToken ct = default)
+ {
+ // using var conn = await _db.OpenAsync(ct);
+
+ // var p = new DynamicParameters();
+ // p.Add("@ClientId", clientId, DbType.String);
+ // p.Add("@ClientSecret", clientSecret, DbType.String);
+
+ // var row = await conn.QueryFirstOrDefaultAsync(
+ // sql: "PA_Sicrep_ValidateClient",
+ // param: p,
+ // commandType: CommandType.StoredProcedure
+ // );
+
+ // if (row is null || row.IsValid == 0)
+ // return new AdsClientCredentialResult(false, null, null, null);
+
+ // return new AdsClientCredentialResult(true, row.ClientId, row.ClientKey, row.ClientName);
+ return null;
+ }
+
+ private sealed class ClientRow
+ {
+ public int IsValid { get; init; }
+ public int ClientId { get; init; }
+ public string ClientKey { get; init; } = string.Empty;
+ public string ClientName { get; init; } = string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsUserCredentialValidator.cs b/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsUserCredentialValidator.cs
new file mode 100644
index 0000000..38db1bc
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Infrastructure/Security/AdsUserCredentialValidator.cs
@@ -0,0 +1,47 @@
+using System.Data;
+using Dapper;
+using ADS.Apis.Gps.Application.Abstractions.Security;
+using ADS.Helpers.Data.Abstractions;
+
+namespace ADS.Apis.Gps.Infrastructure.Security;
+
+public sealed class AdsUserCredentialValidator : IAdsUserCredentialValidator
+{
+ private readonly ITenantDbConnectionFactory _db;
+
+ public AdsUserCredentialValidator(ITenantDbConnectionFactory db)
+ {
+ _db = db;
+ }
+
+ public async Task ValidateAsync(string username, string password, CancellationToken ct = default)
+ {
+ // using var conn = await _db.OpenAsync(ct);
+
+ // // EJEMPLO: si validas con SP
+ // // Cambia el nombre del SP y los nombres de parámetros a tus reales.
+ // var p = new DynamicParameters();
+ // p.Add("@Usuario", username, DbType.String);
+ // p.Add("@Password", password, DbType.String);
+
+ // var row = await conn.QueryFirstOrDefaultAsync(
+ // sql: "PA_Sicrep_ValidateUser",
+ // param: p,
+ // commandType: CommandType.StoredProcedure
+ // );
+
+ // if (row is null || row.IsValid == 0)
+ // return new AdsUserCredentialResult(false, null, null, null);
+
+ // return new AdsUserCredentialResult(true, row.UserId, row.Username, row.DisplayName);
+ return null;
+ }
+
+ private sealed class UserRow
+ {
+ public int IsValid { get; init; }
+ public int UserId { get; init; }
+ public string Username { get; init; } = string.Empty;
+ public string DisplayName { get; init; } = string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Infrastructure/Units/UnitLocationQueryService.cs b/Apis/ADS.Apis.Gps/Infrastructure/Units/UnitLocationQueryService.cs
new file mode 100644
index 0000000..9c101b5
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Infrastructure/Units/UnitLocationQueryService.cs
@@ -0,0 +1,60 @@
+using System.Data;
+using ADS.Apis.Gps.Application.Units;
+using ADS.Helpers.Data;
+using ADS.Helpers.Data.Abstractions;
+using Dapper;
+
+namespace ADS.Apis.Gps.Infrastructure.Units;
+
+public sealed class UnitLocationQueryService : IUnitLocationQueryService
+{
+ private readonly ITenantDbConnectionFactory _connectionFactory;
+
+ public UnitLocationQueryService(ITenantDbConnectionFactory connectionFactory)
+ {
+ _connectionFactory = connectionFactory;
+ }
+
+ public async Task GetLastLocationByNoEconomicoAsync(
+ int cveUsuario,
+ string noEconomico,
+ CancellationToken ct = default)
+ {
+ using var connection = _connectionFactory.Create();
+
+ var command = new CommandDefinition(
+ commandText: "dbo.PA_GPS_DispositovoUbicacionByNoEconomicoGet",
+ parameters: new
+ {
+ CveUsuario = cveUsuario,
+ NoEconomico = noEconomico
+ },
+ commandType: CommandType.StoredProcedure,
+ cancellationToken: ct
+ );
+
+ return await connection.QueryFirstOrDefaultAsync(command);
+ }
+ public async Task> GetLocationsByClientAsync(
+ int cveUsuario,
+ int cveCliente,
+ CancellationToken ct = default)
+{
+ using var connection = _connectionFactory.Create();
+
+ var command = new CommandDefinition(
+ commandText: "dbo.PA_GPS_DispositovoUbicacionPorClienteGet",
+ parameters: new
+ {
+ CveUsuario = cveUsuario,
+ CveCliente = cveCliente
+ },
+ commandType: CommandType.StoredProcedure,
+ cancellationToken: ct
+ );
+
+ var result = await connection.QueryAsync(command);
+
+ return result.AsList();
+}
+}
\ No newline at end of file
diff --git a/Apis/ADS.Apis.Gps/Program.cs b/Apis/ADS.Apis.Gps/Program.cs
new file mode 100644
index 0000000..9f370da
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Program.cs
@@ -0,0 +1,49 @@
+using ADS.Helpers.Data.Extensions;
+using ADS.Helpers.MultiTenant.InternalRouting;
+using ADS.Helpers.Auth.Extensions;
+using ADS.Helpers.Middleware.ADSExtensions;
+using ADS.Helpers.Middleware.Extensions;
+using ADS.Helpers.Middleware.InternalUser;
+using ADS.Apis.Gps.Application.Units;
+using ADS.Apis.Gps.Infrastructure.Units;
+// using Microsoft.OpenApi.Models;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddAdsApiResponse(builder.Configuration);
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+// builder.Services.AddAdsAuthentication(builder.Configuration);
+// builder.Services.AddAdsAuthorization();
+
+// ✅ 1) Middleware interno: valida HMAC + resuelve tenant (debe llenar ITenantContextAccessor.Current)
+builder.Services.AddInternalTenantDb(builder.Configuration);
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+// ✅ 2) Factory transversal (usa tenant.Database.ConnectionString)
+builder.Services.AddAdsTenantDb();
+
+var app = builder.Build();
+
+if (app.Environment.IsDevelopment())
+{
+}
+
+ app.UseSwagger();
+ app.UseSwaggerUI();
+app.UseRouting();
+
+// primero exception middleware (para atrapar todo)
+app.UseAdsApiResponse();
+// ✅ HMAC + set TenantContext
+app.UseInternalTenantDb();
+app.UseAdsInternalUser();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.MapControllers();
+app.Run();
diff --git a/Apis/ADS.Apis.Gps/Properties/launchSettings.json b/Apis/ADS.Apis.Gps/Properties/launchSettings.json
new file mode 100644
index 0000000..f9c2f2c
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5023",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7134;http://localhost:5023",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Apis/ADS.Apis.Gps/appsettings.json b/Apis/ADS.Apis.Gps/appsettings.json
new file mode 100644
index 0000000..e6d1e4f
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/appsettings.json
@@ -0,0 +1,27 @@
+{
+ "InternalRouting": {
+ "SharedSecret": "MISMO_SECRETOJCAUDIADS_DEL_HOST",
+ "MaxSkewSeconds": 120
+ },
+ "Jwt": {
+ "Issuer": "ADS",
+ "Audience": "ADS",
+ "SigningKey": "SUPER_JCau127*953SECRET_KEY_CHANGE_THIS",
+ "AccessTokenMinutes": 60
+ },
+ "LegacyCrypto": {
+ "Mode": "DES",
+ "Key": "$Ap7+5&z",
+ "IV": "1234567890ABCDEF"
+ },
+ "LoggingOptions": {
+ "LogFolder": "C:\\Logs\\ADS\\Gps"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/Apis/ADS.Apis.Gps/readmegpt b/Apis/ADS.Apis.Gps/readmegpt
new file mode 100644
index 0000000..97bb16d
--- /dev/null
+++ b/Apis/ADS.Apis.Gps/readmegpt
@@ -0,0 +1,30 @@
+Quiero rehacer desde cero la API ADS.Apis.Gps con orden limpio (Domain / Application / Infrastructure / Controllers) y multi-tenant por headers internos desde el Host.
+
+Contexto:
+- El Host (gateway) resuelve tenant y manda headers internos firmados (InternalRouting).
+- En el micro GPS ya uso UseInternalTenantDb() para validar firma y poblar TenantContext.
+- TenantContext tiene: EmpresaId, ClienteId, TenantId (string), Culture, Theme, Database { Provider, ConnectionString }, CustomSettings.
+- GPS NO debe leer appsettings para tenants ni duplicar config del Host. NO usar ITenantStore en GPS.
+- La conexión a BD siempre sale de TenantContext.Database.ConnectionString y se abre vía ITenantDbConnectionFactory (ADS.Helpers.Data).
+
+Objetivo inmediato:
+1) Definir la estructura de carpetas y proyectos dentro de ADS.Apis.Gps:
+ - Domain: modelos/puertos (interfaces) sin SQL ni Dapper.
+ - Application: casos de uso/servicios y orquestación.
+ - Infrastructure: Dapper/SQL repos/stores.
+ - Controllers: endpoints.
+2) Implementar autenticación Sicrep en GPS:
+ - Endpoint POST /gps/sicrep/auth/token
+ - Antes de generar JWT, ejecutar el SP [dbo].[PA_Sis_UsuarioValidacionGet] con @Usuario, @Contraseña (varchar(32)), @CveEmpresa (del tenant), y validar que CveUsuario > 0 y Acceso = 1.
+ - Luego emitir JWT usando ADS.Helpers.Auth (IAdsTokenService) con TenantId string y claims (CveUsuario, CveEmpresa, CveCliente, CveRol, etc.)
+3) Entregarme:
+ - Árbol final de carpetas.
+ - Código completo de: puertos (interfaces), modelos domain, repos Infrastructure (Dapper + SP), servicio Application (caso de uso), controller, y registros DI en Program.cs.
+ - NOTA: No uses GetConnectionString("...") ni appsettings para la DB del micro.
+
+Datos extra:
+- Confirmar si la contraseña en BD ya viene en MD5 hex 32 o si debo hashear antes de llamar al SP. (Si no sabes, deja helper para MD5 y comenta dónde se decide).
+IMPORTANTE: No inventes métodos que no existan; si necesitas agregar algo a ADS.Helpers.Auth o ADS.Helpers.MultiTenant/Helpers.Data, dilo explícitamente y dame el código exacto del archivo a modificar.
+pasame un script para la generacion de archivos y carpetas
+tampoco te inventes rutas nuevas las apis las estoy guardando en la carpeta de apis
+la ruta del proyecto debe ser exatamente la misma para que funciione con ADS.Apis.slnx
\ No newline at end of file
diff --git a/Apis/ADS.Apis.slnx b/Apis/ADS.Apis.slnx
new file mode 100644
index 0000000..80fb2da
--- /dev/null
+++ b/Apis/ADS.Apis.slnx
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Apis/ADS.Host/ADS.Host.csproj b/Apis/ADS.Host/ADS.Host.csproj
new file mode 100644
index 0000000..85bb901
--- /dev/null
+++ b/Apis/ADS.Host/ADS.Host.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Apis/ADS.Host/ADS.Host.http b/Apis/ADS.Host/ADS.Host.http
new file mode 100644
index 0000000..3da3666
--- /dev/null
+++ b/Apis/ADS.Host/ADS.Host.http
@@ -0,0 +1,6 @@
+@ADS.Host_HostAddress = http://localhost:5030
+
+GET {{ADS.Host_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs b/Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs
new file mode 100644
index 0000000..035b451
--- /dev/null
+++ b/Apis/ADS.Host/Auth/HostAdsUserCredentialValidator .cs
@@ -0,0 +1,142 @@
+using ADS.Helpers.Auth.Abstractions;
+using ADS.Helpers.MultiTenant.Abstractions;
+using ADS.Helpers.MultiTenant.Context;
+using Dapper;
+using Microsoft.Data.SqlClient;
+using Npgsql;
+using System.Data;
+using System.Text;
+using System.Security.Cryptography;
+
+namespace ADS.Host.Auth;
+
+public class HostAdsUserCredentialValidator : IAdsUserCredentialValidator
+{
+ private readonly ITenantContextAccessor _tenantContextAccessor;
+
+ public HostAdsUserCredentialValidator(ITenantContextAccessor tenantContextAccessor)
+ {
+ _tenantContextAccessor = tenantContextAccessor;
+ }
+
+ public async Task ValidateAsync(
+ string userName,
+ string password,
+ string? tenantId,
+ string? scope,
+ CancellationToken ct = default)
+ {
+ var tenant = _tenantContextAccessor.Current;
+
+ if (tenant == null)
+ {
+ return new AdsUserValidationResult
+ {
+ IsValid = false,
+ Error = "TenantContext no está cargado."
+ };
+ }
+
+ if (tenant.Database == null ||
+ string.IsNullOrWhiteSpace(tenant.Database.ConnectionString))
+ {
+ return new AdsUserValidationResult
+ {
+ IsValid = false,
+ Error = "El tenant no tiene conexión configurada."
+ };
+ }
+
+ using var conn = CreateConnection(
+ tenant.Database.Provider,
+ tenant.Database.ConnectionString);
+
+ var result = await conn.QueryFirstOrDefaultAsync(
+ "dbo.PA_Sis_UsuariosGet",
+ new
+ {
+ CveUsuario = -1,
+ CveRol = -1,
+ CveUsuarioControlado = -1,
+ Usuario = userName,
+ Contraseña = EncriptaDES(password),
+ EsLogIn = true,
+ CvePlataforma = -1,
+ CveEmpresa = -1,
+ IP = "",
+ MAC = "",
+ GrupoEmpresa = "",
+ Estatus = -1
+ },
+ commandType: CommandType.StoredProcedure
+ );
+
+ if (result == null || result.CveUsuario <= 0)
+ {
+ return new AdsUserValidationResult
+ {
+ IsValid = false,
+ Error = "Usuario o contraseña inválidos."
+ };
+ }
+
+ return new AdsUserValidationResult
+ {
+ IsValid = true,
+ UserName=userName,
+ CveUser = result.CveUsuario,
+ CveRole = result.CveRol,
+ CveEmpresa = result.CveEmpresa,
+ CveCliente = result.CveCliente,
+ TenantId = tenant.TenantId
+ };
+ }
+ private static string EncriptaDES(string texto)
+{
+ if (string.IsNullOrEmpty(texto))
+ return "";
+
+ byte[] key = Encoding.UTF8.GetBytes("$Ap7+5&z");
+
+ byte[] iv = new byte[]
+ {
+ 0x12, 0x34, 0x56, 0x78,
+ 0x90, 0xAB, 0xCD, 0xEF
+ };
+
+ using var des = DES.Create();
+ des.Key = key;
+ des.IV = iv;
+ des.Mode = CipherMode.CBC;
+ des.Padding = PaddingMode.PKCS7;
+
+ byte[] input = Encoding.UTF8.GetBytes(texto);
+
+ using var ms = new MemoryStream();
+ using var cs = new CryptoStream(ms, des.CreateEncryptor(), CryptoStreamMode.Write);
+
+ cs.Write(input, 0, input.Length);
+ cs.FlushFinalBlock();
+
+ return Convert.ToBase64String(ms.ToArray());
+}
+ private static IDbConnection CreateConnection(string provider, string connectionString)
+ {
+ return provider?.ToLowerInvariant() switch
+ {
+ "sqlserver" => new SqlConnection(connectionString),
+ "mssql" => new SqlConnection(connectionString),
+ "postgres" => new NpgsqlConnection(connectionString),
+ "postgresql" => new NpgsqlConnection(connectionString),
+ _ => throw new NotSupportedException($"Proveedor de base de datos no soportado: {provider}")
+ };
+ }
+
+ private class UsuarioLoginDbResult
+ {
+ public int CveUsuario { get; set; }
+ public int CveRol { get; set; }
+ public int CveEmpresa { get; set; }
+ public int CveCliente { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs b/Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs
new file mode 100644
index 0000000..47c983f
--- /dev/null
+++ b/Apis/ADS.Host/Auth/StubAdsClientCredentialValidator.cs
@@ -0,0 +1,22 @@
+using ADS.Helpers.Auth.Abstractions;
+
+namespace ADS.Host.Auth;
+
+public sealed class StubAdsClientCredentialValidator : IAdsClientCredentialValidator
+{
+ public Task ValidateAsync(
+ string clientId,
+ string clientSecret,
+ string? tenantId,
+ string? scope,
+ CancellationToken ct = default)
+ {
+ return Task.FromResult(new AdsClientValidationResult
+ {
+ IsValid = false,
+ ClientId = clientId,
+ TenantId = tenantId,
+ Error = "IAdsClientCredentialValidator not configured in ADS.Host."
+ });
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs b/Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs
new file mode 100644
index 0000000..8de35a6
--- /dev/null
+++ b/Apis/ADS.Host/Auth/StubAdsUserCredentialValidator.cs
@@ -0,0 +1,20 @@
+using ADS.Helpers.Auth.Abstractions;
+
+namespace ADS.Host.Auth;
+
+public sealed class StubAdsUserCredentialValidator : IAdsUserCredentialValidator
+{
+ public Task ValidateAsync(
+ string userName,
+ string password,
+ string? tenantId,
+ string? scope,
+ CancellationToken ct = default)
+ {
+ return Task.FromResult(new AdsUserValidationResult
+ {
+ IsValid = false,
+ Error = "IAdsUserCredentialValidator not configured in ADS.Host."
+ });
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Host/Controllers/AuthController.cs b/Apis/ADS.Host/Controllers/AuthController.cs
new file mode 100644
index 0000000..98845a3
--- /dev/null
+++ b/Apis/ADS.Host/Controllers/AuthController.cs
@@ -0,0 +1,29 @@
+using ADS.Helpers.Auth.Abstractions;
+using ADS.Helpers.Auth.Token;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+[ApiController]
+[Route("auth")]
+public class AuthController : ControllerBase
+{
+ private readonly IAdsTokenService _tokenService;
+
+ public AuthController(
+ IAdsTokenService tokenService )
+ {
+ _tokenService = tokenService;
+ }
+
+ [HttpPost("token")]
+ [AllowAnonymous]
+ public async Task Token(
+ [FromBody] AdsTokenRequest request,
+ CancellationToken ct)
+ {
+
+ var token = await _tokenService.IssueAsync(request,ct);
+
+ return Ok(token);
+ }
+}
\ No newline at end of file
diff --git a/Apis/ADS.Host/Controllers/WhoAmIController.cs b/Apis/ADS.Host/Controllers/WhoAmIController.cs
new file mode 100644
index 0000000..fa5d005
--- /dev/null
+++ b/Apis/ADS.Host/Controllers/WhoAmIController.cs
@@ -0,0 +1,36 @@
+using ADS.Helpers.Auth.Abstractions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ADS.Host.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public sealed class WhoAmIController : ControllerBase
+{
+ private readonly IAdsPrincipalAccessor _principal;
+
+ public WhoAmIController(IAdsPrincipalAccessor principal)
+ {
+ _principal = principal;
+ }
+
+ [HttpGet]
+ [Authorize]
+ public IActionResult Get()
+ {
+ var me = _principal.Current;
+
+ return Ok(new
+ {
+ me.CveUser,
+ me.TenantId,
+ me.UserName,
+ me.CveRole,
+ me.CveEmpresa,
+ me.CveCliente,
+ me.CveModulos,
+ me.Permissions
+ });
+ }
+}
diff --git a/Apis/ADS.Host/Program.cs b/Apis/ADS.Host/Program.cs
new file mode 100644
index 0000000..d771a9e
--- /dev/null
+++ b/Apis/ADS.Host/Program.cs
@@ -0,0 +1,125 @@
+using ADS.Helpers.Auth.Abstractions;
+using ADS.Helpers.Auth.Extensions;
+using ADS.Helpers.Middleware.Extensions;
+using ADS.Helpers.MultiTenant.InternalRouting;
+using ADS.Host.Auth;
+using Yarp.ReverseProxy.Transforms;
+using ADS.Helpers.Auth.Claims;
+using System.Security.Cryptography;
+using System.Text;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services .AddReverseProxy()
+ .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
+ .AddTransforms(builderContext =>
+ {
+ builderContext.AddRequestTransform(async transformContext =>
+ {
+ var http = transformContext.HttpContext;
+
+ // Solo para rutas /gps (por si luego agregas más clusters)
+ if (!http.Request.Path.StartsWithSegments("/gps"))
+ return;
+
+ var tenantAccessor = http.RequestServices.GetRequiredService();
+ var writer = http.RequestServices.GetRequiredService();
+
+ var tenant = tenantAccessor.Current;
+ if (tenant is null)
+ return; // o lanza 401/400 si quieres forzarlo
+
+ var conn = tenant.Database.ConnectionString;
+ if (string.IsNullOrWhiteSpace(conn))
+ return; // igual: mejor forzar error si lo deseas
+
+ // Agregar headers internos firmados a la request proxyeada
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ writer.AddInternalHeaders(headers, tenant);
+
+ {
+ var cveUser = http.User.FindFirst(AdsClaimTypes.CveUser)?.Value;
+ var cveEmpresa = http.User.FindFirst(AdsClaimTypes.CveEmpresa)?.Value;
+ var cveCliente = http.User.FindFirst(AdsClaimTypes.CveCliente)?.Value;
+
+ if (!string.IsNullOrWhiteSpace(cveUser) &&
+ !string.IsNullOrWhiteSpace(cveEmpresa) &&
+ !string.IsNullOrWhiteSpace(cveCliente))
+ {
+ var tsUser = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
+ var secret = builderContext.Services
+ .GetRequiredService()["InternalRouting:SharedSecret"];
+
+ var raw = $"{cveUser}|{cveEmpresa}|{cveCliente}|{tsUser}";
+
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret!));
+ var signature = Convert.ToBase64String(
+ hmac.ComputeHash(Encoding.UTF8.GetBytes(raw)));
+
+ headers[InternalHeaders.CveUser] = cveUser;
+ headers[InternalHeaders.CveEmpresa] = cveEmpresa;
+ headers[InternalHeaders.CveCliente] = cveCliente;
+ headers[InternalHeaders.User_Timestamp] = tsUser;
+ headers[InternalHeaders.User_Signature] = signature;
+ }
+ }
+
+
+ foreach (var kv in headers)
+ {
+ transformContext.ProxyRequest.Headers.Remove(kv.Key);
+ transformContext.ProxyRequest.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
+ }
+
+ await ValueTask.CompletedTask;
+ });
+ });
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+// Auth
+builder.Services.AddAdsAuthentication(builder.Configuration);
+builder.Services.AddAdsAuthorization();
+
+// Tenant + Middlewares (ajustar a tus extensiones reales)
+builder.Services.AddAdsMiddlewares(builder.Configuration);
+builder.Services.AddAdsInternalUser();
+
+// ✅ InternalRouting: Host solo necesita el writer (para agregar X-Internal-*)
+builder.Services.AddInternalTenantDbHeaderWriter(builder.Configuration);
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+var app = builder.Build();
+
+
+if (app.Environment.IsDevelopment())
+{
+}
+
+ app.UseSwagger();
+ app.UseSwaggerUI();
+
+// ✅ Muy importante: limpiar headers internos que vengan del cliente
+app.Use((ctx, next) =>
+{
+ ctx.Request.Headers.Remove(InternalHeaders.Tenant);
+ ctx.Request.Headers.Remove(InternalHeaders.Conn);
+ ctx.Request.Headers.Remove(InternalHeaders.Timestamp);
+ ctx.Request.Headers.Remove(InternalHeaders.Signature);
+ ctx.Request.Headers.Remove(InternalHeaders.CveUser);
+ ctx.Request.Headers.Remove(InternalHeaders.CveEmpresa);
+ ctx.Request.Headers.Remove(InternalHeaders.CveCliente);
+ ctx.Request.Headers.Remove(InternalHeaders.User_Timestamp);
+ ctx.Request.Headers.Remove(InternalHeaders.User_Signature);
+ return next();
+});
+app.UseAuthentication();
+app.UseAuthorization();
+app.UseAdsMiddlewares();
+
+
+
+app.MapControllers();
+app.MapReverseProxy();
+app.Run();
diff --git a/Apis/ADS.Host/Properties/launchSettings.json b/Apis/ADS.Host/Properties/launchSettings.json
new file mode 100644
index 0000000..7b9df13
--- /dev/null
+++ b/Apis/ADS.Host/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7000;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Apis/ADS.Host/appsettings.json b/Apis/ADS.Host/appsettings.json
new file mode 100644
index 0000000..bd055e7
--- /dev/null
+++ b/Apis/ADS.Host/appsettings.json
@@ -0,0 +1,62 @@
+{
+ "MultiTenant": {
+ "HeaderName": "X-Tenant",
+ "DefaultTenantId": "demo",
+ "RequireTenant": false,
+ "BypassPaths": ["/swagger", "/health"]
+ },
+
+ "Tenants": {
+ "demo": {
+ "TenantId": "demo",
+ "EmpresaId": 172,
+ "ClienteId": 1,
+ "Culture": "es-MX",
+ "Theme": "default",
+ "Database": {
+ "Provider": "SqlServer",
+ "ConnectionString": "Data Source=(local);Initial Catalog=GPS;User Id=GPSUsr;Password=Productivo2022#;pooling=True; Min Pool Size=10;Application Name=APIDemo"
+ },
+ "CustomSettings": {
+ "GpsVendor": "Concox"
+ }
+ }
+ },
+ "Jwt": {
+ "Issuer": "ADS",
+ "Audience": "ADS_APIS",
+ "SigningKey": "SUPER_JCau127*953SECRET_KEY_CHANGE_THIS",
+ "ValidateLifetime": true,
+ "ClockSkewMinutes": 5
+ },
+
+ "ReverseProxy": {
+ "Routes": {
+ "gpsRoute": {
+ "ClusterId": "gpsCluster",
+ "Match": {
+ "Path": "/gps/{**catch-all}"
+ },
+ "Transforms": [
+ { "PathRemovePrefix": "/gps" }
+ ],
+ "Metadata": {
+ "RequireInternalTenant": "true"
+ }
+ }
+ },
+ "Clusters": {
+ "gpsCluster": {
+ "Destinations": {
+ "gps": {
+ "Address": "http://localhost:5023/"
+ }
+ }
+ }
+ }
+ },
+ "InternalRouting": {
+ "SharedSecret": "MISMO_SECRETO_DEL_HOST",
+ "MaxSkewSeconds": 120
+ }
+}
diff --git a/Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj b/Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj
new file mode 100644
index 0000000..7b69d0a
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/ADS.Helpers.Auth.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Helpers/ADS.Helpers.Auth/Abstractions/IAdsClientCredentialValidator.cs b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsClientCredentialValidator.cs
new file mode 100644
index 0000000..81fbfd3
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsClientCredentialValidator.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ADS.Helpers.Auth.Abstractions;
+
+public sealed class AdsClientValidationResult
+{
+ public bool IsValid { get; set; }
+ public string ClientId { get; set; } = default!;
+
+ public string? TenantId { get; set; }
+ public List ExtraClaims { get; set; } = new();
+
+ public string? Error { get; set; }
+}
+
+public interface IAdsClientCredentialValidator
+{
+ Task ValidateAsync(
+ string clientId,
+ string clientSecret,
+ string? tenantId,
+ string? scope,
+ CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Auth/Abstractions/IAdsPrincipalAccessor.cs b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsPrincipalAccessor.cs
new file mode 100644
index 0000000..4ca9e93
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsPrincipalAccessor.cs
@@ -0,0 +1,9 @@
+using ADS.Helpers.Auth.Models;
+
+namespace ADS.Helpers.Auth.Abstractions;
+
+public interface IAdsPrincipalAccessor
+{
+ AdsPrincipal Current { get; }
+ bool IsAuthenticated { get; }
+}
diff --git a/Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs
new file mode 100644
index 0000000..1087f62
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsTokenService.cs
@@ -0,0 +1,13 @@
+using ADS.Helpers.Auth.Token;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ADS.Helpers.Auth.Abstractions;
+
+public interface IAdsTokenService
+{
+ Task IssueAsync(AdsTokenRequest request, CancellationToken ct = default);
+
+ Task IssueFromClaimsAsync(IReadOnlyCollection claims, string? scope = null, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Auth/Abstractions/IAdsUserCredentialValidator.cs b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsUserCredentialValidator.cs
new file mode 100644
index 0000000..c80f248
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Abstractions/IAdsUserCredentialValidator.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ADS.Helpers.Auth.Abstractions;
+
+public sealed class AdsUserValidationResult
+{
+ public bool IsValid { get; set; }
+ public int CveUser { get; set; }
+ public string? UserName { get; set; }
+
+ public int CveRole { get; set; }
+ public int CveEmpresa { get; set; }
+ public int CveCliente { get; set; }
+
+ public int[] CveModulos { get; set; } = [];
+ public string[] Permissions { get; set; } = [];
+
+ public List ExtraClaims { get; set; } = new();
+
+ public string? Error { get; set; }
+ public string? TenantId { get; set; }
+
+
+}
+
+public interface IAdsUserCredentialValidator
+{
+ Task ValidateAsync(
+ string userName,
+ string password,
+ string? tenantId,
+ string? scope,
+ CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs b/Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs
new file mode 100644
index 0000000..6cb8a9e
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Claims/AdsClaimTypes.cs
@@ -0,0 +1,13 @@
+namespace ADS.Helpers.Auth.Claims;
+
+public static class AdsClaimTypes
+{
+ public const string CveUser = "ads:cve_user";
+ public const string TenantId = "ads:tenant_id";
+ public const string UserName = "ads:username";
+ public const string CveRole = "ads:cve_role";
+ public const string CveEmpresa = "ads:cve_empresa";
+ public const string CveCliente = "ads:cve_cliente";
+ public const string CveModulos = "ads:cve_modulos";
+ public const string Permissions = "ads:permissions";
+}
diff --git a/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenRequest.cs b/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenRequest.cs
new file mode 100644
index 0000000..ad0c508
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenRequest.cs
@@ -0,0 +1,8 @@
+namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
+
+public sealed class TokenRequest
+{
+ public string ClientId { get; set; } = default!;
+ public string ClientSecret { get; set; } = default!;
+ public string? Scope { get; set; }
+}
diff --git a/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenResponse.cs b/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenResponse.cs
new file mode 100644
index 0000000..84bfda2
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Contracts/Sicrep/Auth/TokenResponse.cs
@@ -0,0 +1,9 @@
+namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
+
+public sealed class TokenResponse
+{
+ public string AccessToken { get; set; } = default!;
+ public string TokenType { get; set; } = "Bearer";
+ public int ExpiresIn { get; set; }
+ public string? Scope { get; set; }
+}
diff --git a/Helpers/ADS.Helpers.Auth/Extensions/ServiceCollectionExtensions.cs b/Helpers/ADS.Helpers.Auth/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..de23dc5
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,59 @@
+using System.Text;
+using ADS.Helpers.Auth.Abstractions;
+using ADS.Helpers.Auth.Implementation;
+using ADS.Helpers.Auth.Models;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Tokens;
+
+namespace ADS.Helpers.Auth.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddAdsAuthentication(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddHttpContextAccessor();
+
+ var jwtSection = configuration.GetSection("Jwt");
+ services.Configure(jwtSection);
+
+ var jwtOptions = jwtSection.Get()
+ ?? throw new InvalidOperationException("Jwt configuration missing.");
+
+ var key = new SymmetricSecurityKey(
+ Encoding.UTF8.GetBytes(jwtOptions.SigningKey));
+
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = jwtOptions.ValidateLifetime,
+ ValidateIssuerSigningKey = true,
+ ValidIssuer = jwtOptions.Issuer,
+ ValidAudience = jwtOptions.Audience,
+ IssuerSigningKey = key,
+ ClockSkew = TimeSpan.FromMinutes(jwtOptions.ClockSkewMinutes)
+ };
+ });
+
+ services.AddScoped();
+
+ // NUEVO: servicio emisor de token (requiere que el host registre validadores)
+ services.AddScoped();
+
+ return services;
+ }
+
+ public static IServiceCollection AddAdsAuthorization(
+ this IServiceCollection services)
+ {
+ services.AddAuthorization();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Auth/Implementation/AdsPrincipalAccessor.cs b/Helpers/ADS.Helpers.Auth/Implementation/AdsPrincipalAccessor.cs
new file mode 100644
index 0000000..5b3b4a4
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Implementation/AdsPrincipalAccessor.cs
@@ -0,0 +1,66 @@
+using System.Security.Claims;
+using ADS.Helpers.Auth.Abstractions;
+using ADS.Helpers.Auth.Claims;
+using ADS.Helpers.Auth.Models;
+using Microsoft.AspNetCore.Http;
+
+namespace ADS.Helpers.Auth.Implementation;
+
+internal sealed class AdsPrincipalAccessor : IAdsPrincipalAccessor
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private AdsPrincipal? _cached;
+
+ public AdsPrincipalAccessor(IHttpContextAccessor httpContextAccessor)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public bool IsAuthenticated =>
+ _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true;
+
+ public AdsPrincipal Current
+ {
+ get
+ {
+ if (_cached is not null)
+ return _cached;
+
+ var user = _httpContextAccessor.HttpContext?.User
+ ?? throw new UnauthorizedAccessException("No HttpContext user.");
+
+ _cached = Map(user);
+ return _cached;
+ }
+ }
+
+ private static AdsPrincipal Map(ClaimsPrincipal user)
+ {
+ int ParseInt(string type) =>
+ int.TryParse(user.FindFirst(type)?.Value, out var val) ? val : 0;
+
+ int[] ParseIntArray(string type) =>
+ user.FindAll(type)
+ .Select(x => int.TryParse(x.Value, out var v) ? v : 0)
+ .Where(x => x > 0)
+ .ToArray();
+
+ string[] ParseStringArray(string type) =>
+ user.FindAll(type)
+ .Select(x => x.Value)
+ .ToArray();
+
+ return new AdsPrincipal
+ {
+ CveUser = ParseInt(AdsClaimTypes.CveUser),
+ TenantId = AdsClaimTypes.TenantId,
+ UserName = user.FindFirst(AdsClaimTypes.UserName)?.Value,
+ CveRole = ParseInt(AdsClaimTypes.CveRole),
+ CveEmpresa = ParseInt(AdsClaimTypes.CveEmpresa),
+ CveCliente = ParseInt(AdsClaimTypes.CveCliente),
+ CveModulos = ParseIntArray(AdsClaimTypes.CveModulos),
+ Permissions = ParseStringArray(AdsClaimTypes.Permissions),
+ Claims = user.Claims.ToDictionary(x => x.Type, x => x.Value)
+ };
+ }
+}
diff --git a/Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs b/Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs
new file mode 100644
index 0000000..e09748d
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Implementation/AdsTokenService.cs
@@ -0,0 +1,158 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using ADS.Helpers.Auth.Abstractions;
+using ADS.Helpers.Auth.Claims;
+using ADS.Helpers.Auth.Models;
+using ADS.Helpers.Auth.Token;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+
+namespace ADS.Helpers.Auth.Implementation;
+
+internal sealed class AdsTokenService : IAdsTokenService
+{
+ private readonly JwtOptions _options;
+ private readonly IAdsUserCredentialValidator _userValidator;
+ private readonly IAdsClientCredentialValidator _clientValidator;
+
+ public AdsTokenService(
+ IOptions options,
+ IAdsUserCredentialValidator userValidator,
+ IAdsClientCredentialValidator clientValidator)
+ {
+ _options = options.Value ?? throw new InvalidOperationException("JwtOptions missing.");
+ _userValidator = userValidator ?? throw new ArgumentNullException(nameof(userValidator));
+ _clientValidator = clientValidator ?? throw new ArgumentNullException(nameof(clientValidator));
+ }
+
+ public async Task IssueAsync(AdsTokenRequest request, CancellationToken ct = default)
+ {
+ if (request is null) throw new ArgumentNullException(nameof(request));
+ if (string.IsNullOrWhiteSpace(request.GrantType)) throw new InvalidOperationException("GrantType is required.");
+
+ var grant = request.GrantType.Trim().ToLowerInvariant();
+
+ if (grant == AdsGrantTypes.Password)
+ return await IssuePasswordAsync(request, ct);
+
+ if (grant == AdsGrantTypes.ClientCredentials)
+ return await IssueClientCredentialsAsync(request, ct);
+
+ throw new InvalidOperationException($"Unsupported GrantType: {request.GrantType}");
+ }
+
+ private async Task IssuePasswordAsync(AdsTokenRequest request, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(request.UserName))
+ throw new InvalidOperationException("UserName is required for password grant.");
+
+ if (string.IsNullOrWhiteSpace(request.Password))
+ throw new InvalidOperationException("Password is required for password grant.");
+
+ var validation = await _userValidator.ValidateAsync(
+ request.UserName.Trim(),
+ request.Password,
+ request.TenantId,
+ request.Scope,
+ ct);
+
+ if (!validation.IsValid)
+ throw new UnauthorizedAccessException(validation.Error ?? "Invalid credentials.");
+
+ var claims = new List
+ {
+ new(AdsClaimTypes.CveUser, validation.CveUser.ToString()),
+ new(AdsClaimTypes.UserName, validation.UserName ?? request.UserName.Trim()),
+ new(AdsClaimTypes.CveRole, validation.CveRole.ToString()),
+ new(AdsClaimTypes.CveEmpresa, validation.CveEmpresa.ToString()),
+ new(AdsClaimTypes.CveCliente, validation.CveCliente.ToString()),
+ };
+
+ if (!string.IsNullOrWhiteSpace(request.TenantId))
+ claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
+
+ foreach (var m in validation.CveModulos.Where(x => x > 0))
+ claims.Add(new Claim(AdsClaimTypes.CveModulos, m.ToString()));
+
+ foreach (var p in validation.Permissions.Where(x => !string.IsNullOrWhiteSpace(x)))
+ claims.Add(new Claim(AdsClaimTypes.Permissions, p));
+
+ if (validation.ExtraClaims.Count > 0)
+ claims.AddRange(validation.ExtraClaims);
+
+ return BuildToken(claims, request.Scope);
+ }
+
+ private async Task IssueClientCredentialsAsync(AdsTokenRequest request, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(request.ClientId))
+ throw new InvalidOperationException("ClientId is required for client_credentials grant.");
+
+ if (string.IsNullOrWhiteSpace(request.ClientSecret))
+ throw new InvalidOperationException("ClientSecret is required for client_credentials grant.");
+
+ var validation = await _clientValidator.ValidateAsync(
+ request.ClientId.Trim(),
+ request.ClientSecret,
+ request.TenantId,
+ request.Scope,
+ ct);
+
+ if (!validation.IsValid)
+ throw new UnauthorizedAccessException(validation.Error ?? "Invalid client credentials.");
+
+ var claims = new List
+ {
+ new(AdsClaimTypes.UserName, validation.ClientId),
+ };
+
+ var tenantId = validation.TenantId ?? request.TenantId;
+ if (!string.IsNullOrWhiteSpace(request.TenantId))
+ claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
+
+ if (validation.ExtraClaims.Count > 0)
+ claims.AddRange(validation.ExtraClaims);
+
+ return BuildToken(claims, request.Scope);
+ }
+
+ private AdsTokenResult BuildToken(IReadOnlyCollection claims, string? scope)
+ {
+ if (string.IsNullOrWhiteSpace(_options.SigningKey))
+ throw new InvalidOperationException("JwtOptions.SigningKey is required.");
+
+ var now = DateTime.UtcNow;
+ var expires = now.AddMinutes(_options.AccessTokenMinutes <= 0 ? 60 : _options.AccessTokenMinutes);
+
+ var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
+ var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
+
+ var jwt = new JwtSecurityToken(
+ issuer: _options.Issuer,
+ audience: _options.Audience,
+ claims: claims,
+ notBefore: now,
+ expires: expires,
+ signingCredentials: creds
+ );
+
+ var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
+
+ return new AdsTokenResult
+ {
+ AccessToken = accessToken,
+ TokenType = "Bearer",
+ ExpiresIn = (int)Math.Max(0, (expires - now).TotalSeconds),
+ Scope = scope
+ };
+ }
+
+ public Task IssueFromClaimsAsync(IReadOnlyCollection claims, string? scope = null, CancellationToken ct = default)
+ {
+ if (claims is null) throw new ArgumentNullException(nameof(claims));
+ // ct no se usa, pero lo dejamos por consistencia
+ return Task.FromResult(BuildToken(claims, scope));
+ }
+
+}
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs b/Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs
new file mode 100644
index 0000000..c20e083
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Models/AdsPrincipal.cs
@@ -0,0 +1,22 @@
+namespace ADS.Helpers.Auth.Models;
+
+public sealed class AdsPrincipal
+{
+ public int CveUser { get; init; }
+
+ public string? TenantId { get; init; }
+
+ public string? UserName { get; init; }
+
+ public int CveRole { get; init; }
+
+ public int CveEmpresa { get; init; }
+
+ public int CveCliente { get; init; }
+
+ public int[] CveModulos { get; init; } = [];
+
+ public string[] Permissions { get; init; } = [];
+
+ public Dictionary Claims { get; init; } = new();
+}
diff --git a/Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs b/Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs
new file mode 100644
index 0000000..2e3fc48
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Models/JwtOptions.cs
@@ -0,0 +1,11 @@
+namespace ADS.Helpers.Auth.Models;
+
+public sealed class JwtOptions
+{
+ public string Issuer { get; set; } = default!;
+ public string Audience { get; set; } = default!;
+ public string SigningKey { get; set; } = default!;
+ public bool ValidateLifetime { get; set; } = true;
+ public int ClockSkewMinutes { get; set; } = 5;
+ public int AccessTokenMinutes { get; set; } = 60;
+}
diff --git a/Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs b/Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs
new file mode 100644
index 0000000..47b4afe
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Token/AdsTokenRequest.cs
@@ -0,0 +1,26 @@
+namespace ADS.Helpers.Auth.Token;
+
+public static class AdsGrantTypes
+{
+ public const string Password = "password";
+ public const string ClientCredentials = "client_credentials";
+}
+
+public sealed class AdsTokenRequest
+{
+ public string GrantType { get; set; } = AdsGrantTypes.Password;
+
+ // password
+ public string? UserName { get; set; }
+ public string? Password { get; set; }
+
+ // client_credentials
+ public string? ClientId { get; set; }
+ public string? ClientSecret { get; set; }
+
+ // multitenant / contexto
+ public string? TenantId { get; set; }
+
+ // opcional: scope
+ public string? Scope { get; set; }
+}
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs b/Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs
new file mode 100644
index 0000000..31aa6b7
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/Token/AdsTokenResult.cs
@@ -0,0 +1,9 @@
+namespace ADS.Helpers.Auth.Token;
+
+public sealed class AdsTokenResult
+{
+ public string AccessToken { get; set; } = default!;
+ public string TokenType { get; set; } = "Bearer";
+ public int ExpiresIn { get; set; }
+ public string? Scope { get; set; }
+}
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Auth/readme.md b/Helpers/ADS.Helpers.Auth/readme.md
new file mode 100644
index 0000000..652d595
--- /dev/null
+++ b/Helpers/ADS.Helpers.Auth/readme.md
@@ -0,0 +1,44 @@
+9️⃣ appsettings.json ejemplo
+
+"Jwt": {
+ "Issuer": "ADS",
+ "Audience": "ADS_APIS",
+ "SigningKey": "SUPER_SECRET_KEY_CHANGE_THIS",
+ "ValidateLifetime": true,
+ "ClockSkewMinutes": 5
+}
+
+
+🔟 Cómo usarlo en el Host o API GPS
+
+builder.Services.AddAdsAuthentication(builder.Configuration);
+builder.Services.AddAdsAuthorization();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+Y en cualquier controlador:
+
+public class GpsController : ControllerBase
+{
+ private readonly IAdsPrincipalAccessor _principal;
+
+ public GpsController(IAdsPrincipalAccessor principal)
+ {
+ _principal = principal;
+ }
+
+ [HttpGet]
+ public IActionResult Get()
+ {
+ var user = _principal.Current;
+
+ return Ok(new
+ {
+ user.CveUser,
+ user.TenantId,
+ user.CveEmpresa,
+ user.CveModulos
+ });
+ }
+}
diff --git a/Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj b/Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj
new file mode 100644
index 0000000..b760144
--- /dev/null
+++ b/Helpers/ADS.Helpers.Base/ADS.Helpers.Base.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs b/Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs
new file mode 100644
index 0000000..6bf89ac
--- /dev/null
+++ b/Helpers/ADS.Helpers.Base/Abstractions/IAppLogger.cs
@@ -0,0 +1,8 @@
+namespace ADS.Helpers.Base.Abstractions;
+
+public interface IAppLogger
+{
+ void Info(string source, string code, string message, object? data = null);
+ void Warn(string source, string code, string message, object? data = null);
+ void Error(string source, string code, string message, Exception? ex = null, object? data = null);
+}
diff --git a/Helpers/ADS.Helpers.Base/Class1.cs b/Helpers/ADS.Helpers.Base/Class1.cs
new file mode 100644
index 0000000..6fe6a75
--- /dev/null
+++ b/Helpers/ADS.Helpers.Base/Class1.cs
@@ -0,0 +1,6 @@
+namespace ADS.Helpers.Base;
+
+public class Class1
+{
+
+}
diff --git a/Helpers/ADS.Helpers.Base/readme.md b/Helpers/ADS.Helpers.Base/readme.md
new file mode 100644
index 0000000..4869a1f
--- /dev/null
+++ b/Helpers/ADS.Helpers.Base/readme.md
@@ -0,0 +1,9 @@
+✅ 4️⃣ En tu API (Host)
+
+En Program.cs:
+
+builder.Services.AddAdsLogging();
+builder.Services.AddAdsNotifyCenter();
+
+
+Y listo.
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj b/Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj
new file mode 100644
index 0000000..34cddc8
--- /dev/null
+++ b/Helpers/ADS.Helpers.Data/ADS.Helpers.Data.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Helpers/ADS.Helpers.Data/Abstractions/ITenantDbConnectionFactory.cs b/Helpers/ADS.Helpers.Data/Abstractions/ITenantDbConnectionFactory.cs
new file mode 100644
index 0000000..7627946
--- /dev/null
+++ b/Helpers/ADS.Helpers.Data/Abstractions/ITenantDbConnectionFactory.cs
@@ -0,0 +1,8 @@
+using System.Data;
+
+namespace ADS.Helpers.Data.Abstractions;
+
+public interface ITenantDbConnectionFactory
+{
+ IDbConnection Create();
+}
diff --git a/Helpers/ADS.Helpers.Data/Extensions/ServiceCollectionExtensions.cs b/Helpers/ADS.Helpers.Data/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..0808a2a
--- /dev/null
+++ b/Helpers/ADS.Helpers.Data/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,13 @@
+using ADS.Helpers.Data.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ADS.Helpers.Data.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddAdsTenantDb(this IServiceCollection services)
+ {
+ services.AddScoped();
+ return services;
+ }
+}
diff --git a/Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs b/Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs
new file mode 100644
index 0000000..d378d7d
--- /dev/null
+++ b/Helpers/ADS.Helpers.Data/TenantDbConnectionFactory.cs
@@ -0,0 +1,26 @@
+using System.Data;
+using ADS.Helpers.Data.Abstractions;
+using ADS.Helpers.MultiTenant.Context; // o .Abstractions (según donde esté tu interfaz)
+using Microsoft.Data.SqlClient;
+
+namespace ADS.Helpers.Data;
+
+public sealed class TenantDbConnectionFactory : ITenantDbConnectionFactory
+{
+ private readonly ITenantContextAccessor _tenantAccessor;
+
+ public TenantDbConnectionFactory(ITenantContextAccessor tenantAccessor)
+ => _tenantAccessor = tenantAccessor;
+
+ public IDbConnection Create()
+ {
+ var tenant = _tenantAccessor.Current
+ ?? throw new InvalidOperationException("TENANT_NOT_RESOLVED");
+
+ var cs = tenant.Database?.ConnectionString;
+ if (string.IsNullOrWhiteSpace(cs))
+ throw new InvalidOperationException("TENANT_CONNECTION_STRING_MISSING");
+
+ return new SqlConnection(cs);
+ }
+}
diff --git a/Helpers/ADS.Helpers.Data/readme.md b/Helpers/ADS.Helpers.Data/readme.md
new file mode 100644
index 0000000..27cb6b7
--- /dev/null
+++ b/Helpers/ADS.Helpers.Data/readme.md
@@ -0,0 +1,15 @@
+7) Usarlo en cualquier micro (GPS incluido)
+
+En el Program.cs del micro:
+
+using ADS.Helpers.Data.Extensions;
+
+builder.Services.AddAdsMiddlewares(builder.Configuration); // tu pipeline tenant
+builder.Services.AddAdsTenantDb();
+
+
+Y en repositorios (Dapper):
+
+using ADS.Helpers.Data.Abstractions;
+
+using var cn = _dbFactory.Create();
\ No newline at end of file
diff --git a/Helpers/ADS.Helpers.Localization/ADS.Helpers.Localization.csproj b/Helpers/ADS.Helpers.Localization/ADS.Helpers.Localization.csproj
new file mode 100644
index 0000000..aa52f4d
--- /dev/null
+++ b/Helpers/ADS.Helpers.Localization/ADS.Helpers.Localization.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/Helpers/ADS.Helpers.Localization/Abstractions/IMessageCatalog.cs b/Helpers/ADS.Helpers.Localization/Abstractions/IMessageCatalog.cs
new file mode 100644
index 0000000..7013f89
--- /dev/null
+++ b/Helpers/ADS.Helpers.Localization/Abstractions/IMessageCatalog.cs
@@ -0,0 +1,6 @@
+namespace ADS.Helpers.Localization.Abstractions;
+
+public interface IMessageCatalog
+{
+ string Get(string source,string code, string culture);
+}
diff --git a/Helpers/ADS.Helpers.Localization/Class1.cs b/Helpers/ADS.Helpers.Localization/Class1.cs
new file mode 100644
index 0000000..87c2a23
--- /dev/null
+++ b/Helpers/ADS.Helpers.Localization/Class1.cs
@@ -0,0 +1,6 @@
+namespace ADS.Helpers.Localization;
+
+public class Class1
+{
+
+}
diff --git a/Helpers/ADS.Helpers.Localization/Extensions/ServiceCollectionExtensions.cs b/Helpers/ADS.Helpers.Localization/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..9be11b4
--- /dev/null
+++ b/Helpers/ADS.Helpers.Localization/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,14 @@
+using ADS.Helpers.Localization.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ADS.Helpers.Localization.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddAdsLocalization(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ return services;
+ }
+}
diff --git a/Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs b/Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs
new file mode 100644
index 0000000..0a1d7d5
--- /dev/null
+++ b/Helpers/ADS.Helpers.Localization/InMemoryMessageCatalog.cs
@@ -0,0 +1,28 @@
+using System.Collections.Concurrent;
+using ADS.Helpers.Localization.Abstractions;
+
+namespace ADS.Helpers.Localization;
+
+public sealed class InMemoryMessageCatalog : IMessageCatalog
+{
+ private readonly ConcurrentDictionary<(string source, string code, string culture), string> _messages = new();
+
+ public InMemoryMessageCatalog Seed(string source, string culture, IReadOnlyDictionary messages)
+ {
+ foreach (var kv in messages)
+ _messages[(source, kv.Key, culture)] = kv.Value;
+
+ return this;
+ }
+
+ public string Get(string source, string code, string culture)
+ {
+ if (_messages.TryGetValue((source, code, culture), out var msg))
+ return msg;
+
+ if (_messages.TryGetValue((source, code, "en-US"), out var en))
+ return en;
+
+ return code;
+ }
+}
diff --git a/Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj b/Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj
new file mode 100644
index 0000000..b8ee1bf
--- /dev/null
+++ b/Helpers/ADS.Helpers.Middleware/ADS.Helpers.Middleware.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Helpers/ADS.Helpers.Middleware/ADSException/AdsExceptionMiddleware.cs b/Helpers/ADS.Helpers.Middleware/ADSException/AdsExceptionMiddleware.cs
new file mode 100644
index 0000000..3443331
--- /dev/null
+++ b/Helpers/ADS.Helpers.Middleware/ADSException/AdsExceptionMiddleware.cs
@@ -0,0 +1,82 @@
+using System.Net;
+using System.Text.Json;
+using Microsoft.AspNetCore.Http;
+
+namespace ADS.Helpers.Middleware.ADSException;
+
+using ADS.Helpers.Middleware.Http;
+
+public sealed class AdsExceptionMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly IAdsErrorLogger _logger;
+
+ public AdsExceptionMiddleware(RequestDelegate next, IAdsErrorLogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (System.UnauthorizedAccessException ex)
+ {
+ await Handle(context, HttpStatusCode.Unauthorized, ex, AdsErrorKind.Controlled);
+ }
+ catch (ArgumentException ex)
+ {
+ await Handle(context, HttpStatusCode.BadRequest, ex, AdsErrorKind.Controlled);
+ }
+ catch (InvalidOperationException ex)
+ {
+ await Handle(context, HttpStatusCode.BadRequest, ex, AdsErrorKind.Controlled);
+ }
+ catch (Microsoft.Data.SqlClient.SqlException ex)
+ {
+ await Handle(context, HttpStatusCode.InternalServerError, ex, AdsErrorKind.Sql);
+ }
+ catch (Exception ex)
+ {
+ await Handle(context, HttpStatusCode.InternalServerError, ex, AdsErrorKind.Unhandled);
+ }
+ }
+
+ private async Task Handle(HttpContext ctx, HttpStatusCode code, Exception ex, AdsErrorKind kind)
+ {
+ await _logger.LogAsync(new AdsErrorEvent
+ {
+ Kind = kind,
+ Path = ctx.Request.Path.ToString(),
+ Method = ctx.Request.Method,
+ TraceId = ctx.TraceIdentifier,
+ Message = ex.Message,
+ Exception = ex.ToString()
+ });
+
+ ctx.Response.StatusCode = (int)code;
+ ctx.Response.ContentType = "application/json";
+
+ var payload = new ApiResponse