diff --git a/.gitignore b/.gitignore
index ed6d1d2..d0b32af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,6 +63,9 @@ project.lock.json
project.fragment.lock.json
artifacts/
+# Local deployment settings containing machine-specific credentials
+**/appsettings.*.local.json
+
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
diff --git a/Antifraude.Net/Antifraude.Net.sln b/Antifraude.Net/Antifraude.Net.sln
index 140d918..86d993e 100644
--- a/Antifraude.Net/Antifraude.Net.sln
+++ b/Antifraude.Net/Antifraude.Net.sln
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiDenuncias", "ApiDenuncia
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GestionaDenuncias.Shared", "GestionaDenuncias.Shared\GestionaDenuncias.Shared.csproj", "{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiOPE", "ApiOPE\ApiOPE.csproj", "{EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -111,6 +113,18 @@ Global
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x64.Build.0 = Release|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x86.ActiveCfg = Release|Any CPU
{94F25A9A-6084-4F8C-9FF1-F74C6BF83B0E}.Release|x86.Build.0 = Release|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x64.Build.0 = Debug|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Debug|x86.Build.0 = Debug|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x64.ActiveCfg = Release|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x64.Build.0 = Release|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x86.ActiveCfg = Release|Any CPU
+ {EB6E98EA-6A41-473F-BA2E-0927ABBAB4C5}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj b/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj
index 47e9f0c..d4a7be9 100644
--- a/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj
+++ b/Antifraude.Net/ApiDenuncias/ApiDenuncias.csproj
@@ -20,6 +20,10 @@
+
+
+
+
diff --git a/Antifraude.Net/ApiDenuncias/Configuration/OpeBridgeOptions.cs b/Antifraude.Net/ApiDenuncias/Configuration/OpeBridgeOptions.cs
new file mode 100644
index 0000000..353da37
--- /dev/null
+++ b/Antifraude.Net/ApiDenuncias/Configuration/OpeBridgeOptions.cs
@@ -0,0 +1,10 @@
+namespace ApiDenuncias.Configuration;
+
+public sealed class OpeBridgeOptions
+{
+ public const string SectionName = "OpeBridge";
+
+ public string ApiKey { get; set; } = string.Empty;
+
+ public string ApiKeyHeaderName { get; set; } = "X-ApiOPE-Key";
+}
diff --git a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs
index b62a387..b6dcf22 100644
--- a/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs
+++ b/Antifraude.Net/ApiDenuncias/Controllers/DenunciasController.cs
@@ -2,6 +2,7 @@ using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ApiDenuncias.Services;
+using ApiDenuncias.Security;
using GestionaDenuncias.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -86,6 +87,7 @@ public sealed class DenunciasController : ControllerBase
}
[AllowAnonymous]
+ [ServiceFilter(typeof(OpeBridgeApiKeyFilter))]
[HttpGet("{denunciaId:int}/gestiona-fields")]
public async Task> GetGestionaFields(
int denunciaId,
@@ -106,6 +108,7 @@ public sealed class DenunciasController : ControllerBase
}
[AllowAnonymous]
+ [ServiceFilter(typeof(OpeBridgeApiKeyFilter))]
[HttpGet("{numeroExpediente:int}/{anioExpediente:int}/gestiona-fields")]
public async Task> GetGestionaFieldsByExpediente(
int numeroExpediente,
diff --git a/Antifraude.Net/ApiDenuncias/Program.cs b/Antifraude.Net/ApiDenuncias/Program.cs
index 2f6e588..47959e8 100644
--- a/Antifraude.Net/ApiDenuncias/Program.cs
+++ b/Antifraude.Net/ApiDenuncias/Program.cs
@@ -1,25 +1,29 @@
using System.Net.Http.Headers;
using System.Text;
using ApiDenuncias.Configuration;
+using ApiDenuncias.Security;
using ApiDenuncias.Services;
-using ApiDenuncias.Configuration;
using GestionaDenuncias.Shared.Models;
-using ApiDenuncias.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
-using GestionaDenuncias.Shared.Models;
var builder = WebApplication.CreateBuilder(args);
+builder.Configuration.AddJsonFile(
+ $"appsettings.{builder.Environment.EnvironmentName}.local.json",
+ optional: true,
+ reloadOnChange: false);
+
builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionName));
builder.Services.Configure(builder.Configuration.GetSection(KeyVaultOptions.SectionName));
builder.Services.Configure(builder.Configuration.GetSection("Gestiona"));
builder.Services.Configure(builder.Configuration.GetSection(GlobalLeaksOptions.SectionName));
builder.Services.Configure(builder.Configuration.GetSection(ComplaintStorageOptions.SectionName));
builder.Services.Configure(builder.Configuration.GetSection(ManualPurgeOptions.SectionName));
+builder.Services.Configure(builder.Configuration.GetSection(OpeBridgeOptions.SectionName));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
@@ -48,6 +52,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddHttpClient();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddHttpClient((sp, client) =>
{
diff --git a/Antifraude.Net/ApiDenuncias/Security/OpeBridgeApiKeyFilter.cs b/Antifraude.Net/ApiDenuncias/Security/OpeBridgeApiKeyFilter.cs
new file mode 100644
index 0000000..c4fed72
--- /dev/null
+++ b/Antifraude.Net/ApiDenuncias/Security/OpeBridgeApiKeyFilter.cs
@@ -0,0 +1,58 @@
+using System.Security.Cryptography;
+using System.Text;
+using ApiDenuncias.Configuration;
+using GestionaDenuncias.Shared.Models;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.Options;
+
+namespace ApiDenuncias.Security;
+
+public sealed class OpeBridgeApiKeyFilter : IAsyncActionFilter
+{
+ private readonly OpeBridgeOptions _options;
+ private readonly ILogger _logger;
+
+ public OpeBridgeApiKeyFilter(
+ IOptions options,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public async Task OnActionExecutionAsync(
+ ActionExecutingContext context,
+ ActionExecutionDelegate next)
+ {
+ if (string.IsNullOrWhiteSpace(_options.ApiKey) || _options.ApiKey.Length < 32)
+ {
+ _logger.LogError("La clave interna de ApiOPE no esta configurada.");
+ context.Result = new ObjectResult(new ApiError("El acceso interno OPE no esta configurado."))
+ {
+ StatusCode = StatusCodes.Status503ServiceUnavailable
+ };
+ return;
+ }
+
+ if (!context.HttpContext.Request.Headers.TryGetValue(_options.ApiKeyHeaderName, out var values) ||
+ values.Count != 1 ||
+ !FixedTimeEquals(values[0] ?? string.Empty, _options.ApiKey))
+ {
+ _logger.LogWarning(
+ "Intento no autorizado de acceso interno OPE en {Path}.",
+ context.HttpContext.Request.Path);
+ context.Result = new UnauthorizedObjectResult(new ApiError("No autorizado."));
+ return;
+ }
+
+ await next();
+ }
+
+ private static bool FixedTimeEquals(string supplied, string expected)
+ {
+ var suppliedHash = SHA256.HashData(Encoding.UTF8.GetBytes(supplied));
+ var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(expected));
+ return CryptographicOperations.FixedTimeEquals(suppliedHash, expectedHash);
+ }
+}
diff --git a/Antifraude.Net/ApiDenuncias/appsettings.Development.json b/Antifraude.Net/ApiDenuncias/appsettings.Development.json
index 7e84ef1..afe2aac 100644
--- a/Antifraude.Net/ApiDenuncias/appsettings.Development.json
+++ b/Antifraude.Net/ApiDenuncias/appsettings.Development.json
@@ -15,5 +15,9 @@
"ConnectionString": "Server=192.168.41.25;Port=13306;Database=gestiondenuncias;Uid=tecnosis;Pwd=tsl4net.Ts87;",
"UseKeyVault": false,
"AutoCreateSchema": true
+ },
+ "OpeBridge": {
+ "ApiKey": "development-only-internal-ope-key-change-me",
+ "ApiKeyHeaderName": "X-ApiOPE-Key"
}
}
diff --git a/Antifraude.Net/ApiDenuncias/appsettings.json b/Antifraude.Net/ApiDenuncias/appsettings.json
index 6f24d06..4c2c64b 100644
--- a/Antifraude.Net/ApiDenuncias/appsettings.json
+++ b/Antifraude.Net/ApiDenuncias/appsettings.json
@@ -71,5 +71,9 @@
"DefaultPort": 3306,
"DefaultSslMode": "Required",
"AutoCreateSchema": true
+ },
+ "OpeBridge": {
+ "ApiKey": "",
+ "ApiKeyHeaderName": "X-ApiOPE-Key"
}
}
diff --git a/Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj b/Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj
new file mode 100644
index 0000000..486d5eb
--- /dev/null
+++ b/Antifraude.Net/ApiOPE.Tests/ApiOPE.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs b/Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/Antifraude.Net/ApiOPE.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs b/Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs
new file mode 100644
index 0000000..5b2789a
--- /dev/null
+++ b/Antifraude.Net/ApiOPE.Tests/OpeFieldMapperTests.cs
@@ -0,0 +1,92 @@
+using ApiOPE.Configuration;
+using ApiOPE.Contracts;
+using ApiOPE.Services;
+using Microsoft.Extensions.Options;
+
+namespace ApiOPE.Tests;
+
+public sealed class OpeFieldMapperTests
+{
+ [Fact]
+ public void Map_UsesConfiguredOrderAndFieldNames()
+ {
+ var options = Options.Create(new OpeOptions
+ {
+ OutputFields = ["numeroDenunciaCanal", "fechaDenuncia", "resumenDenuncia"]
+ });
+ var mapper = new OpeFieldMapper(options);
+ var source = new InternalGestionaFieldsResponse(
+ new Dictionary
+ {
+ ["fechaDenuncia"] = new("STRING", "2026-07-10"),
+ ["numeroDenunciaCanal"] = new("STRING", "116"),
+ ["resumenDenuncia"] = new("STRING", "Prueba")
+ });
+
+ var result = mapper.Map(source);
+
+ Assert.Equal("116", result.Data["FIELD_0"].Value);
+ Assert.Equal("2026-07-10", result.Data["FIELD_1"].Value);
+ Assert.Equal("Prueba", result.Data["FIELD_2"].Value);
+ }
+
+ [Fact]
+ public void Map_RejectsMissingInternalField()
+ {
+ var mapper = new OpeFieldMapper(Options.Create(new OpeOptions
+ {
+ OutputFields = ["fechaDenuncia"]
+ }));
+ var source = new InternalGestionaFieldsResponse(
+ new Dictionary());
+
+ Assert.Throws(() => mapper.Map(source));
+ }
+
+ [Theory]
+ [InlineData("53/2026", "api/denuncias/53/2026/gestiona-fields")]
+ [InlineData("116", "api/denuncias/116/gestiona-fields")]
+ public void LookupParser_AcceptsExpedienteAndComplaintId(string value, string expectedPath)
+ {
+ var parsed = DenunciaLookupParser.TryParse(value, out var lookup);
+
+ Assert.True(parsed);
+ Assert.Equal(expectedPath, lookup!.RelativePath);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(11)]
+ public void OptionsValidator_RejectsInvalidOutputCount(int count)
+ {
+ var options = new OpeOptions
+ {
+ ClientToken = "client",
+ SecretKey = "test-secret-key-with-more-than-32-characters",
+ PublicBaseUrl = "https://ope.example.test",
+ OrganizationId = "organization",
+ OrganizationDir3 = "dir3",
+ OrganizationCif = "cif",
+ OutputFields = Enumerable.Repeat("fechaDenuncia", count).ToList()
+ };
+
+ Assert.False(OpeOptionsValidator.IsValid(options, isDevelopment: false));
+ }
+
+ [Fact]
+ public void OptionsValidator_AcceptsTenUniqueConfiguredFields()
+ {
+ var options = new OpeOptions
+ {
+ ClientToken = "client",
+ SecretKey = "test-secret-key-with-more-than-32-characters",
+ PublicBaseUrl = "https://ope.example.test",
+ OrganizationId = "organization",
+ OrganizationDir3 = "dir3",
+ OrganizationCif = "cif",
+ OutputFields = OpeFieldMapper.SupportedInternalFields.Take(10).ToList()
+ };
+
+ Assert.True(OpeOptionsValidator.IsValid(options, isDevelopment: false));
+ }
+}
diff --git a/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs b/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs
new file mode 100644
index 0000000..fa55d26
--- /dev/null
+++ b/Antifraude.Net/ApiOPE.Tests/OpeProtocolTests.cs
@@ -0,0 +1,83 @@
+using System.Text;
+using ApiOPE.Configuration;
+using ApiOPE.Contracts;
+using ApiOPE.Security;
+using ApiOPE.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+
+namespace ApiOPE.Tests;
+
+public sealed class OpeProtocolTests
+{
+ [Fact]
+ public void ResponseSigner_MatchesGestionaHexThenBase64Algorithm()
+ {
+ var signer = new OpeResponseSigner(Options.Create(new OpeOptions
+ {
+ SecretKey = "test-secret-key-with-more-than-32-characters"
+ }));
+
+ var signature = signer.Sign("26cd0da5-c45f-497d-b380-820c03cf3237");
+
+ Assert.Equal(
+ "MmE0MjEwM2Y1NTM2NjRkM2FmMzRhNDZlNmQxNjE0NzVmNmE0OTQxNDUxN2NlYmU2ODZjOTZiYzg5YmFjY2FmYw==",
+ signature);
+ }
+
+ [Fact]
+ public void ResponseSigner_PreservesUuidTextCasing()
+ {
+ var signer = new OpeResponseSigner(Options.Create(new OpeOptions
+ {
+ SecretKey = "test-secret-key-with-more-than-32-characters"
+ }));
+
+ Assert.NotEqual(
+ signer.Sign("26cd0da5-c45f-497d-b380-820c03cf3237"),
+ signer.Sign("26CD0DA5-C45F-497D-B380-820C03CF3237"));
+ }
+
+ [Fact]
+ public async Task RequestReader_AcceptsVendorJsonMediaType()
+ {
+ var context = CreateHttpContext(
+ "application/vnd.generic-operation-request+json; charset=utf-8",
+ """
+ {"data":{"FIELD_0":{"type":"STRING","value":"53/2026"}}}
+ """);
+
+ var result = await GenericOperationRequestReader.ReadAsync(
+ context.Request,
+ CancellationToken.None);
+
+ Assert.True(result.IsValid);
+ Assert.Equal("53/2026", result.Request!.Data["FIELD_0"].Value);
+ }
+
+ [Fact]
+ public async Task RequestReader_ReturnsFieldErrorsForUnexpectedInput()
+ {
+ var context = CreateHttpContext(
+ OpeMediaTypes.GenericOperationRequest,
+ """
+ {"data":{"FIELD_1":{"type":"STRING","value":"unexpected"}}}
+ """);
+
+ var result = await GenericOperationRequestReader.ReadAsync(
+ context.Request,
+ CancellationToken.None);
+
+ Assert.False(result.IsValid);
+ Assert.Equal(OpeFieldErrors.NotExpected, result.Errors["FIELD_1"]);
+ Assert.Equal(OpeFieldErrors.Expected, result.Errors["FIELD_0"]);
+ }
+
+ private static DefaultHttpContext CreateHttpContext(string contentType, string body)
+ {
+ var context = new DefaultHttpContext();
+ context.Request.ContentType = contentType;
+ context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
+ return context;
+ }
+}
diff --git a/Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs b/Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs
new file mode 100644
index 0000000..8ab47a8
--- /dev/null
+++ b/Antifraude.Net/ApiOPE.Tests/OpeTokenValidatorTests.cs
@@ -0,0 +1,164 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using ApiOPE.Configuration;
+using ApiOPE.Security;
+using Microsoft.Extensions.Options;
+
+namespace ApiOPE.Tests;
+
+public sealed class OpeTokenValidatorTests
+{
+ private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeSeconds(1_800_000_000);
+
+ [Fact]
+ public void Validate_AcceptsSignedCurrentToken()
+ {
+ var options = CreateOptions();
+ var validator = CreateValidator(options);
+ var token = CreateToken(options, "/genericoperations", Now.ToUnixTimeSeconds(), includeVersion: true);
+
+ var result = validator.Validate(token, "/genericoperations", versionOptional: false);
+
+ Assert.True(result.IsValid);
+ Assert.NotNull(result.RequestContext);
+ Assert.Equal("/genericoperations", result.RequestContext.Resource);
+ }
+
+ [Fact]
+ public void Validate_RejectsManipulatedSignature()
+ {
+ var options = CreateOptions();
+ var validator = CreateValidator(options);
+ var token = CreateToken(
+ options,
+ "/genericoperations",
+ Now.ToUnixTimeSeconds(),
+ includeVersion: true,
+ signingSecret: "another-secret-key-that-is-long-enough");
+
+ var result = validator.Validate(token, "/genericoperations", versionOptional: false);
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public void Validate_RejectsExpiredTimestamp()
+ {
+ var options = CreateOptions();
+ var validator = CreateValidator(options);
+ var token = CreateToken(
+ options,
+ "/genericoperations",
+ Now.AddMinutes(-7).ToUnixTimeSeconds(),
+ includeVersion: true);
+
+ var result = validator.Validate(token, "/genericoperations", versionOptional: false);
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public void Validate_RejectsDifferentResource()
+ {
+ var options = CreateOptions();
+ var validator = CreateValidator(options);
+ var token = CreateToken(options, "/", Now.ToUnixTimeSeconds(), includeVersion: true);
+
+ var result = validator.Validate(token, "/genericoperations", versionOptional: false);
+
+ Assert.False(result.IsValid);
+ }
+
+ [Fact]
+ public void Validate_AllowsMissingVersionOnlyForVersionEndpoint()
+ {
+ var options = CreateOptions();
+ var validator = CreateValidator(options);
+ var token = CreateToken(options, "/versions/current", Now.ToUnixTimeSeconds(), includeVersion: false);
+
+ var versionResult = validator.Validate(token, "/versions/current", versionOptional: true);
+ var operationResult = validator.Validate(token, "/versions/current", versionOptional: false);
+
+ Assert.True(versionResult.IsValid);
+ Assert.False(operationResult.IsValid);
+ }
+
+ [Fact]
+ public void Validate_PreservesOriginalUuidTextForResponseSignature()
+ {
+ var options = CreateOptions();
+ var validator = CreateValidator(options);
+ const string uppercaseUuid = "26CD0DA5-C45F-497D-B380-820C03CF3237";
+ var token = CreateToken(
+ options,
+ "/genericoperations",
+ Now.ToUnixTimeSeconds(),
+ includeVersion: true,
+ uuid: uppercaseUuid);
+
+ var result = validator.Validate(token, "/genericoperations", versionOptional: false);
+
+ Assert.True(result.IsValid);
+ Assert.Equal(uppercaseUuid, result.RequestContext!.Uuid);
+ }
+
+ private static OpeTokenValidator CreateValidator(OpeOptions options)
+ => new(Options.Create(options), new FixedTimeProvider(Now));
+
+ private static OpeOptions CreateOptions()
+ => new()
+ {
+ ClientToken = "client-token",
+ SecretKey = "test-secret-key-with-more-than-32-characters",
+ Version = "1.0",
+ TokenMaxAgeSeconds = 300,
+ ClockSkewSeconds = 60
+ };
+
+ private static string CreateToken(
+ OpeOptions options,
+ string resource,
+ long timestamp,
+ bool includeVersion,
+ string? signingSecret = null,
+ string uuid = "26cd0da5-c45f-497d-b380-820c03cf3237")
+ {
+ var header = Base64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
+ var payloadValues = new Dictionary
+ {
+ ["client_token"] = options.ClientToken,
+ ["resource"] = resource,
+ ["uuid"] = uuid,
+ ["transaction_id"] = "37ec011f-4f0b-4a7b-a807-a83e88c73ce9",
+ ["timestamp"] = timestamp
+ };
+ if (includeVersion)
+ {
+ payloadValues["version"] = options.Version;
+ }
+
+ var payload = Base64Url(JsonSerializer.SerializeToUtf8Bytes(payloadValues));
+ var signedContent = Encoding.ASCII.GetBytes($"{header}.{payload}");
+ var signature = HMACSHA256.HashData(
+ Encoding.UTF8.GetBytes(signingSecret ?? options.SecretKey),
+ signedContent);
+
+ return $"{header}.{payload}.{Base64Url(signature)}";
+ }
+
+ private static string Base64Url(byte[] value)
+ => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ private sealed class FixedTimeProvider : TimeProvider
+ {
+ private readonly DateTimeOffset _utcNow;
+
+ public FixedTimeProvider(DateTimeOffset utcNow)
+ {
+ _utcNow = utcNow;
+ }
+
+ public override DateTimeOffset GetUtcNow() => _utcNow;
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/ApiOPE.csproj b/Antifraude.Net/ApiOPE/ApiOPE.csproj
new file mode 100644
index 0000000..157726c
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/ApiOPE.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Antifraude.Net/ApiOPE/ApiOPE.http b/Antifraude.Net/ApiOPE/ApiOPE.http
new file mode 100644
index 0000000..1182c88
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/ApiOPE.http
@@ -0,0 +1,39 @@
+@ApiOPE_HostAddress = http://localhost:5090
+@OrganizationId = 00000000-0000-0000-0000-000000000001
+@OrganizationDir3 = DEV000001
+@OrganizationCif = DEV000001
+
+# Los JWT deben generarse con el SecretKey del conector y un uuid nuevo.
+# El resource debe coincidir exactamente con la ruta de cada peticion.
+
+GET {{ApiOPE_HostAddress}}/versions/current
+X-Rest-Basic-Token:
+X-Organization-ID: {{OrganizationId}}
+X-Organization-DIR3: {{OrganizationDir3}}
+X-Organization-CIF: {{OrganizationCif}}
+
+###
+
+GET {{ApiOPE_HostAddress}}/
+X-Rest-Basic-Token:
+X-Organization-ID: {{OrganizationId}}
+X-Organization-DIR3: {{OrganizationDir3}}
+X-Organization-CIF: {{OrganizationCif}}
+
+###
+
+POST {{ApiOPE_HostAddress}}/genericoperations
+Content-Type: application/vnd.generic-operation-request+json
+X-Rest-Basic-Token:
+X-Organization-ID: {{OrganizationId}}
+X-Organization-DIR3: {{OrganizationDir3}}
+X-Organization-CIF: {{OrganizationCif}}
+
+{
+ "data": {
+ "FIELD_0": {
+ "type": "STRING",
+ "value": "53/2026"
+ }
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs b/Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs
new file mode 100644
index 0000000..c2c1abe
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Configuration/InternalApiOptions.cs
@@ -0,0 +1,30 @@
+namespace ApiOPE.Configuration;
+
+public sealed class InternalApiOptions
+{
+ public const string SectionName = "InternalApi";
+
+ public string BaseUrl { get; set; } = "http://localhost:7093";
+
+ public string ApiKey { get; set; } = string.Empty;
+
+ public string ApiKeyHeaderName { get; set; } = "X-ApiOPE-Key";
+
+ public int TimeoutSeconds { get; set; } = 20;
+}
+
+public static class InternalApiOptionsValidator
+{
+ public static bool IsValid(InternalApiOptions options)
+ {
+ return Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var baseUri) &&
+ (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps) &&
+ string.IsNullOrEmpty(baseUri.UserInfo) &&
+ string.IsNullOrEmpty(baseUri.Query) &&
+ string.IsNullOrEmpty(baseUri.Fragment) &&
+ !string.IsNullOrWhiteSpace(options.ApiKey) &&
+ options.ApiKey.Length >= 32 &&
+ string.Equals(options.ApiKeyHeaderName, "X-ApiOPE-Key", StringComparison.OrdinalIgnoreCase) &&
+ options.TimeoutSeconds is >= 1 and <= 60;
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs b/Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs
new file mode 100644
index 0000000..53b14ef
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Configuration/OpeOptions.cs
@@ -0,0 +1,62 @@
+using ApiOPE.Services;
+
+namespace ApiOPE.Configuration;
+
+public sealed class OpeOptions
+{
+ public const string SectionName = "Ope";
+
+ public string Version { get; set; } = "1.0";
+
+ public string ClientToken { get; set; } = string.Empty;
+
+ public string SecretKey { get; set; } = string.Empty;
+
+ public string PublicBaseUrl { get; set; } = string.Empty;
+
+ public int TokenMaxAgeSeconds { get; set; } = 300;
+
+ public int ClockSkewSeconds { get; set; } = 60;
+
+ public string OrganizationId { get; set; } = string.Empty;
+
+ public string OrganizationDir3 { get; set; } = string.Empty;
+
+ public string OrganizationCif { get; set; } = string.Empty;
+
+ public List OutputFields { get; set; } = [];
+}
+
+public static class OpeOptionsValidator
+{
+ public static bool IsValid(OpeOptions options, bool isDevelopment)
+ {
+ if (!string.Equals(options.Version, "1.0", StringComparison.Ordinal) ||
+ string.IsNullOrWhiteSpace(options.ClientToken) ||
+ string.IsNullOrWhiteSpace(options.SecretKey) ||
+ options.SecretKey.Length < 32 ||
+ string.IsNullOrWhiteSpace(options.OrganizationId) ||
+ string.IsNullOrWhiteSpace(options.OrganizationDir3) ||
+ string.IsNullOrWhiteSpace(options.OrganizationCif) ||
+ options.TokenMaxAgeSeconds is < 1 or > 900 ||
+ options.ClockSkewSeconds is < 0 or > 300 ||
+ options.OutputFields is null ||
+ options.OutputFields.Count is < 1 or > 10 ||
+ options.OutputFields.Distinct(StringComparer.Ordinal).Count() != options.OutputFields.Count ||
+ options.OutputFields.Any(field => !OpeFieldMapper.SupportedInternalFields.Contains(field)))
+ {
+ return false;
+ }
+
+ if (!Uri.TryCreate(options.PublicBaseUrl, UriKind.Absolute, out var publicBaseUri) ||
+ (publicBaseUri.Scheme != Uri.UriSchemeHttps && publicBaseUri.Scheme != Uri.UriSchemeHttp) ||
+ !string.IsNullOrEmpty(publicBaseUri.UserInfo) ||
+ !string.IsNullOrEmpty(publicBaseUri.Query) ||
+ !string.IsNullOrEmpty(publicBaseUri.Fragment))
+ {
+ return false;
+ }
+
+ return isDevelopment || publicBaseUri.Scheme == Uri.UriSchemeHttps;
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs b/Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs
new file mode 100644
index 0000000..44d0c33
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Contracts/InternalApiContracts.cs
@@ -0,0 +1,6 @@
+using System.Text.Json.Serialization;
+
+namespace ApiOPE.Contracts;
+
+public sealed record InternalGestionaFieldsResponse(
+ [property: JsonPropertyName("data")] IReadOnlyDictionary Data);
diff --git a/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs b/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs
new file mode 100644
index 0000000..a24502d
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Contracts/OpeContracts.cs
@@ -0,0 +1,49 @@
+using System.Text.Json.Serialization;
+
+namespace ApiOPE.Contracts;
+
+public sealed record OpeFieldValue(
+ [property: JsonPropertyName("type")] string Type,
+ [property: JsonPropertyName("value")] string Value);
+
+public sealed record OpeDataEnvelope(
+ [property: JsonPropertyName("data")] IReadOnlyDictionary Data);
+
+public sealed record OpeVersionResponse(
+ [property: JsonPropertyName("version")] string Version);
+
+public sealed record OpeTypedInfo(
+ [property: JsonPropertyName("element")] string? Element,
+ [property: JsonPropertyName("action")] string? Action,
+ [property: JsonPropertyName("type")] string? Type,
+ [property: JsonPropertyName("cause")] string Cause);
+
+public sealed record OpeErrorResponse(
+ [property: JsonPropertyName("status_code")] int StatusCode,
+ [property: JsonPropertyName("message")] string Message,
+ [property: JsonPropertyName("typed_info")] OpeTypedInfo TypedInfo,
+ [property: JsonPropertyName("data")] IReadOnlyDictionary? Data);
+
+public static class OpeMediaTypes
+{
+ public const string Version = "application/vnd.version+json";
+ public const string Bookmarks = "application/vnd.bookmark-list+json";
+ public const string GenericOperationRequest = "application/vnd.generic-operation-request+json";
+ public const string GenericOperationResponse = "application/vnd.generic-operation-response+json";
+ public const string Error = "application/vnd.generic-operation.error+json";
+}
+
+public static class OpeErrorCauses
+{
+ public const string WrongSignature = "WRONG_SIGNATURE";
+ public const string FieldErrors = "FIELD_ERRORS";
+ public const string ElementNotExists = "ELEMENT_NOT_EXISTS";
+ public const string ConnectorError = "CONNECTOR_ERROR";
+}
+
+public static class OpeFieldErrors
+{
+ public const string Expected = "FIELD_EXPECTED";
+ public const string NotExpected = "FIELD_NOT_EXPECTED";
+ public const string UnexpectedFormat = "FIELD_UNEXPECTED_FORMAT";
+}
diff --git a/Antifraude.Net/ApiOPE/Controllers/OpeController.cs b/Antifraude.Net/ApiOPE/Controllers/OpeController.cs
new file mode 100644
index 0000000..9ef1a46
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Controllers/OpeController.cs
@@ -0,0 +1,164 @@
+using System.Text.Json;
+using ApiOPE.Configuration;
+using ApiOPE.Contracts;
+using ApiOPE.Security;
+using ApiOPE.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace ApiOPE.Controllers;
+
+[ApiController]
+[Route("")]
+public sealed class OpeController : ControllerBase
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ private readonly OpeOptions _options;
+ private readonly InternalDenunciasClient _internalApi;
+ private readonly OpeFieldMapper _fieldMapper;
+ private readonly ILogger _logger;
+
+ public OpeController(
+ IOptions options,
+ InternalDenunciasClient internalApi,
+ OpeFieldMapper fieldMapper,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _internalApi = internalApi;
+ _fieldMapper = fieldMapper;
+ _logger = logger;
+ }
+
+ [HttpGet("versions/current")]
+ [OpeAuthenticated("/versions/current", versionOptional: true)]
+ [Produces(OpeMediaTypes.Version)]
+ public IActionResult GetCurrentVersion()
+ => Json(new OpeVersionResponse(_options.Version), OpeMediaTypes.Version);
+
+ [HttpGet]
+ [OpeAuthenticated("/")]
+ [Produces(OpeMediaTypes.Bookmarks)]
+ public IActionResult GetBookmarks()
+ {
+ var genericOperationUrl = $"{_options.PublicBaseUrl.TrimEnd('/')}/genericoperations";
+ return Json(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["generic-operation"] = genericOperationUrl
+ },
+ OpeMediaTypes.Bookmarks);
+ }
+
+ [HttpPost("genericoperations")]
+ [OpeAuthenticated("/genericoperations")]
+ [RequestSizeLimit(64 * 1024)]
+ [Produces(OpeMediaTypes.GenericOperationResponse)]
+ [ProducesResponseType(typeof(OpeErrorResponse), StatusCodes.Status412PreconditionFailed)]
+ public async Task ExecuteGenericOperation(CancellationToken cancellationToken)
+ {
+ var requestContext = OpeRequestContextStore.Get(HttpContext)
+ ?? throw new InvalidOperationException("No existe contexto de autenticacion OPE.");
+
+ var parsedRequest = await GenericOperationRequestReader.ReadAsync(Request, cancellationToken);
+ if (!parsedRequest.IsValid)
+ {
+ return Error(
+ "Existen errores en los campos",
+ OpeErrorCauses.FieldErrors,
+ parsedRequest.Errors);
+ }
+
+ var identifier = parsedRequest.Request!.Data["FIELD_0"].Value;
+ if (!DenunciaLookupParser.TryParse(identifier, out var lookup) || lookup is null)
+ {
+ return Error(
+ "Existen errores en los campos",
+ OpeErrorCauses.FieldErrors,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["FIELD_0"] = OpeFieldErrors.UnexpectedFormat
+ });
+ }
+
+ try
+ {
+ var result = await _internalApi.GetFieldsAsync(
+ lookup,
+ requestContext.TransactionId,
+ cancellationToken);
+
+ if (result.Status == InternalLookupStatus.NotFound || result.Fields is null)
+ {
+ return Error(
+ "No se ha encontrado la denuncia o expediente solicitado",
+ OpeErrorCauses.ElementNotExists,
+ null);
+ }
+
+ return Json(_fieldMapper.Map(result.Fields), OpeMediaTypes.GenericOperationResponse);
+ }
+ catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested)
+ {
+ _logger.LogWarning(
+ "Timeout consultando ApiDenuncias para una OPE. TransactionId={TransactionId}",
+ requestContext.TransactionId);
+ return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
+ }
+ catch (HttpRequestException exception)
+ {
+ _logger.LogWarning(
+ exception,
+ "No se ha podido conectar con ApiDenuncias. TransactionId={TransactionId}",
+ requestContext.TransactionId);
+ return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
+ }
+ catch (InternalApiException exception)
+ {
+ _logger.LogWarning(
+ exception,
+ "ApiDenuncias ha devuelto una respuesta no utilizable. TransactionId={TransactionId}",
+ requestContext.TransactionId);
+ return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
+ }
+ catch (InvalidDataException exception)
+ {
+ _logger.LogError(
+ exception,
+ "El contrato entre ApiOPE y ApiDenuncias no coincide. TransactionId={TransactionId}",
+ requestContext.TransactionId);
+ return Error("Error en el conector", OpeErrorCauses.ConnectorError, null);
+ }
+ }
+
+ private static ContentResult Error(
+ string message,
+ string cause,
+ IReadOnlyDictionary? data)
+ {
+ var response = new OpeErrorResponse(
+ StatusCodes.Status412PreconditionFailed,
+ message,
+ new OpeTypedInfo(null, null, null, cause),
+ data);
+
+ return Json(
+ response,
+ OpeMediaTypes.Error,
+ StatusCodes.Status412PreconditionFailed);
+ }
+
+ private static ContentResult Json(
+ T value,
+ string contentType,
+ int statusCode = StatusCodes.Status200OK)
+ {
+ return new ContentResult
+ {
+ Content = JsonSerializer.Serialize(value, JsonOptions),
+ ContentType = contentType,
+ StatusCode = statusCode
+ };
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Postman/ApiOPE-PRE.postman_environment.json b/Antifraude.Net/ApiOPE/Postman/ApiOPE-PRE.postman_environment.json
new file mode 100644
index 0000000..fffa075
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Postman/ApiOPE-PRE.postman_environment.json
@@ -0,0 +1,51 @@
+{
+ "id": "02213a08-7ce6-49bc-8ca0-0d12965cfdb2",
+ "name": "ApiOPE PRE",
+ "values": [
+ {
+ "key": "base_url",
+ "value": "http://158.158.42.110:7094",
+ "type": "default",
+ "enabled": true
+ },
+ {
+ "key": "client_token",
+ "value": "REEMPLAZAR_CLIENT_TOKEN_GESTIONA",
+ "type": "secret",
+ "enabled": true
+ },
+ {
+ "key": "secret_key",
+ "value": "REEMPLAZAR_SECRET_KEY_GESTIONA",
+ "type": "secret",
+ "enabled": true
+ },
+ {
+ "key": "organization_id",
+ "value": "REEMPLAZAR_ORGANIZATION_ID",
+ "type": "default",
+ "enabled": true
+ },
+ {
+ "key": "organization_dir3",
+ "value": "REEMPLAZAR_ORGANIZATION_DIR3",
+ "type": "default",
+ "enabled": true
+ },
+ {
+ "key": "organization_cif",
+ "value": "REEMPLAZAR_ORGANIZATION_CIF",
+ "type": "default",
+ "enabled": true
+ },
+ {
+ "key": "expediente_gestiona",
+ "value": "REEMPLAZAR_EXPEDIENTE_NO_PURGADO",
+ "type": "default",
+ "enabled": true
+ }
+ ],
+ "_postman_variable_scope": "environment",
+ "_postman_exported_at": "2026-08-07T00:00:00.000Z",
+ "_postman_exported_using": "Postman/12"
+}
diff --git a/Antifraude.Net/ApiOPE/Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json b/Antifraude.Net/ApiOPE/Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json
new file mode 100644
index 0000000..a84b1f1
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json
@@ -0,0 +1,360 @@
+{
+ "info": {
+ "_postman_id": "6d0ba12e-f700-4d79-88f7-6fe9c70c8acf",
+ "name": "ApiOPE - Simulacion Gestiona",
+ "description": "Simula las tres llamadas consecutivas que realiza Gestiona contra un conector OPE 1.0. Genera automaticamente el JWT HS256 y valida la firma Signature de cada respuesta.",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "event": [
+ {
+ "listen": "prerequest",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "function utf8Bytes(value) {",
+ " return Array.from(new TextEncoder().encode(value));",
+ "}",
+ "",
+ "function rightRotate(value, amount) {",
+ " return (value >>> amount) | (value << (32 - amount));",
+ "}",
+ "",
+ "function sha256Bytes(input) {",
+ " const constants = [",
+ " 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,",
+ " 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,",
+ " 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,",
+ " 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,",
+ " 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,",
+ " 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,",
+ " 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,",
+ " 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2",
+ " ];",
+ " const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];",
+ " const bytes = Array.from(input);",
+ " const bitLength = bytes.length * 8;",
+ " bytes.push(0x80);",
+ " while (bytes.length % 64 !== 56) bytes.push(0);",
+ " const high = Math.floor(bitLength / 0x100000000);",
+ " const low = bitLength >>> 0;",
+ " for (let shift = 24; shift >= 0; shift -= 8) bytes.push((high >>> shift) & 0xff);",
+ " for (let shift = 24; shift >= 0; shift -= 8) bytes.push((low >>> shift) & 0xff);",
+ "",
+ " for (let offset = 0; offset < bytes.length; offset += 64) {",
+ " const words = new Array(64);",
+ " for (let i = 0; i < 16; i += 1) {",
+ " const index = offset + (i * 4);",
+ " words[i] = ((bytes[index] << 24) | (bytes[index + 1] << 16) | (bytes[index + 2] << 8) | bytes[index + 3]) >>> 0;",
+ " }",
+ " for (let i = 16; i < 64; i += 1) {",
+ " const s0 = rightRotate(words[i - 15], 7) ^ rightRotate(words[i - 15], 18) ^ (words[i - 15] >>> 3);",
+ " const s1 = rightRotate(words[i - 2], 17) ^ rightRotate(words[i - 2], 19) ^ (words[i - 2] >>> 10);",
+ " words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;",
+ " }",
+ "",
+ " let [a, b, c, d, e, f, g, h] = state;",
+ " for (let i = 0; i < 64; i += 1) {",
+ " const sum1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);",
+ " const choice = (e & f) ^ ((~e) & g);",
+ " const temp1 = (h + sum1 + choice + constants[i] + words[i]) >>> 0;",
+ " const sum0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);",
+ " const majority = (a & b) ^ (a & c) ^ (b & c);",
+ " const temp2 = (sum0 + majority) >>> 0;",
+ " h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;",
+ " }",
+ "",
+ " state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0;",
+ " state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;",
+ " state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0;",
+ " state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;",
+ " }",
+ "",
+ " const output = [];",
+ " state.forEach(word => output.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff));",
+ " return output;",
+ "}",
+ "",
+ "function hmacSha256Bytes(message, secret) {",
+ " let key = utf8Bytes(secret);",
+ " if (key.length > 64) key = sha256Bytes(key);",
+ " while (key.length < 64) key.push(0);",
+ " const inner = key.map(value => value ^ 0x36).concat(utf8Bytes(message));",
+ " const outer = key.map(value => value ^ 0x5c).concat(sha256Bytes(inner));",
+ " return sha256Bytes(outer);",
+ "}",
+ "",
+ "function bytesToBase64(bytes) {",
+ " let binary = '';",
+ " bytes.forEach(value => { binary += String.fromCharCode(value); });",
+ " return btoa(binary);",
+ "}",
+ "",
+ "function toBase64Url(bytes) {",
+ " return bytesToBase64(bytes).replace(/=+$/, '').replace(/\\+/g, '-').replace(/\\//g, '_');",
+ "}",
+ "",
+ "const requests = {",
+ " '01 - Consultar version': { resource: '/versions/current', includeVersion: false, resetTransaction: true },",
+ " '02 - Consultar bookmarks': { resource: '/', includeVersion: true, resetTransaction: false },",
+ " '03 - Consultar datos de denuncia': { resource: '/genericoperations', includeVersion: true, resetTransaction: false }",
+ "};",
+ "",
+ "const current = requests[pm.info.requestName];",
+ "if (!current) {",
+ " throw new Error(`No existe configuracion OPE para la peticion ${pm.info.requestName}`);",
+ "}",
+ "",
+ "const requiredVariables = [",
+ " 'base_url',",
+ " 'client_token',",
+ " 'secret_key',",
+ " 'organization_id',",
+ " 'organization_dir3',",
+ " 'organization_cif'",
+ "];",
+ "",
+ "for (const variableName of requiredVariables) {",
+ " const value = pm.variables.get(variableName);",
+ " if (!value || value.startsWith('REEMPLAZAR_')) {",
+ " throw new Error(`Debes rellenar la variable ${variableName} en el entorno de Postman`);",
+ " }",
+ "}",
+ "",
+ "if (current.resource === '/genericoperations') {",
+ " const expediente = pm.variables.get('expediente_gestiona');",
+ " if (!expediente || expediente.startsWith('REEMPLAZAR_')) {",
+ " throw new Error('Debes indicar un expediente no purgado en expediente_gestiona');",
+ " }",
+ "}",
+ "",
+ "if (current.resetTransaction || !pm.collectionVariables.get('ope_transaction_id')) {",
+ " pm.collectionVariables.set('ope_transaction_id', pm.variables.replaceIn('{{$guid}}'));",
+ "}",
+ "",
+ "const requestUuid = pm.variables.replaceIn('{{$guid}}');",
+ "const payload = {",
+ " client_token: pm.variables.get('client_token'),",
+ " resource: current.resource,",
+ " uuid: requestUuid,",
+ " timestamp: Math.floor(Date.now() / 1000),",
+ " transaction_id: pm.collectionVariables.get('ope_transaction_id')",
+ "};",
+ "",
+ "if (current.includeVersion) {",
+ " payload.version = pm.variables.get('ope_version') || '1.0';",
+ "}",
+ "",
+ "const headerPart = toBase64Url(utf8Bytes(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));",
+ "const payloadPart = toBase64Url(utf8Bytes(JSON.stringify(payload)));",
+ "const signedContent = `${headerPart}.${payloadPart}`;",
+ "const signaturePart = toBase64Url(hmacSha256Bytes(signedContent, pm.variables.get('secret_key')));",
+ "const token = `${signedContent}.${signaturePart}`;",
+ "",
+ "pm.collectionVariables.set('ope_uuid', requestUuid);",
+ "pm.collectionVariables.set('ope_jwt', token);"
+ ]
+ }
+ },
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "function utf8Bytes(value) {",
+ " return Array.from(new TextEncoder().encode(value));",
+ "}",
+ "",
+ "function rightRotate(value, amount) {",
+ " return (value >>> amount) | (value << (32 - amount));",
+ "}",
+ "",
+ "function sha256Bytes(input) {",
+ " const constants = [",
+ " 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,",
+ " 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,",
+ " 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,",
+ " 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,",
+ " 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,",
+ " 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,",
+ " 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,",
+ " 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2",
+ " ];",
+ " const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];",
+ " const bytes = Array.from(input);",
+ " const bitLength = bytes.length * 8;",
+ " bytes.push(0x80);",
+ " while (bytes.length % 64 !== 56) bytes.push(0);",
+ " const high = Math.floor(bitLength / 0x100000000);",
+ " const low = bitLength >>> 0;",
+ " for (let shift = 24; shift >= 0; shift -= 8) bytes.push((high >>> shift) & 0xff);",
+ " for (let shift = 24; shift >= 0; shift -= 8) bytes.push((low >>> shift) & 0xff);",
+ "",
+ " for (let offset = 0; offset < bytes.length; offset += 64) {",
+ " const words = new Array(64);",
+ " for (let i = 0; i < 16; i += 1) {",
+ " const index = offset + (i * 4);",
+ " words[i] = ((bytes[index] << 24) | (bytes[index + 1] << 16) | (bytes[index + 2] << 8) | bytes[index + 3]) >>> 0;",
+ " }",
+ " for (let i = 16; i < 64; i += 1) {",
+ " const s0 = rightRotate(words[i - 15], 7) ^ rightRotate(words[i - 15], 18) ^ (words[i - 15] >>> 3);",
+ " const s1 = rightRotate(words[i - 2], 17) ^ rightRotate(words[i - 2], 19) ^ (words[i - 2] >>> 10);",
+ " words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;",
+ " }",
+ "",
+ " let [a, b, c, d, e, f, g, h] = state;",
+ " for (let i = 0; i < 64; i += 1) {",
+ " const sum1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);",
+ " const choice = (e & f) ^ ((~e) & g);",
+ " const temp1 = (h + sum1 + choice + constants[i] + words[i]) >>> 0;",
+ " const sum0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22);",
+ " const majority = (a & b) ^ (a & c) ^ (b & c);",
+ " const temp2 = (sum0 + majority) >>> 0;",
+ " h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;",
+ " }",
+ "",
+ " state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0;",
+ " state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;",
+ " state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0;",
+ " state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;",
+ " }",
+ "",
+ " const output = [];",
+ " state.forEach(word => output.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff));",
+ " return output;",
+ "}",
+ "",
+ "function hmacSha256Bytes(message, secret) {",
+ " let key = utf8Bytes(secret);",
+ " if (key.length > 64) key = sha256Bytes(key);",
+ " while (key.length < 64) key.push(0);",
+ " const inner = key.map(value => value ^ 0x36).concat(utf8Bytes(message));",
+ " const outer = key.map(value => value ^ 0x5c).concat(sha256Bytes(inner));",
+ " return sha256Bytes(outer);",
+ "}",
+ "",
+ "function bytesToBase64(bytes) {",
+ " let binary = '';",
+ " bytes.forEach(value => { binary += String.fromCharCode(value); });",
+ " return btoa(binary);",
+ "}",
+ "",
+ "function bytesToHex(bytes) {",
+ " return bytes.map(value => value.toString(16).padStart(2, '0')).join('');",
+ "}",
+ "",
+ "const expectedContentTypes = {",
+ " '01 - Consultar version': 'application/vnd.version+json',",
+ " '02 - Consultar bookmarks': 'application/vnd.bookmark-list+json',",
+ " '03 - Consultar datos de denuncia': 'application/vnd.generic-operation-response+json'",
+ "};",
+ "",
+ "pm.test('HTTP 200', () => {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "",
+ "pm.test('Content-Type OPE correcto', () => {",
+ " const actual = (pm.response.headers.get('Content-Type') || '').split(';')[0].trim();",
+ " pm.expect(actual).to.eql(expectedContentTypes[pm.info.requestName]);",
+ "});",
+ "",
+ "pm.test('Firma Signature valida', () => {",
+ " const uuid = pm.collectionVariables.get('ope_uuid');",
+ " const secret = pm.variables.get('secret_key');",
+ " const lowercaseHex = bytesToHex(hmacSha256Bytes(uuid, secret));",
+ " const expected = bytesToBase64(utf8Bytes(lowercaseHex));",
+ " pm.expect(pm.response.headers.get('Signature')).to.eql(expected);",
+ "});",
+ "",
+ "const body = pm.response.json();",
+ "",
+ "if (pm.info.requestName === '01 - Consultar version') {",
+ " pm.test('Version 1.0', () => pm.expect(body.version).to.eql('1.0'));",
+ "}",
+ "",
+ "if (pm.info.requestName === '02 - Consultar bookmarks') {",
+ " pm.test('Bookmark de operacion generica correcto', () => {",
+ " const baseUrl = pm.variables.get('base_url').replace(/\\/$/, '');",
+ " pm.expect(body['generic-operation']).to.eql(`${baseUrl}/genericoperations`);",
+ " });",
+ "}",
+ "",
+ "if (pm.info.requestName === '03 - Consultar datos de denuncia') {",
+ " pm.test('Respuesta contiene entre 1 y 10 campos STRING', () => {",
+ " pm.expect(body).to.have.property('data');",
+ " const entries = Object.entries(body.data);",
+ " pm.expect(entries.length).to.be.within(1, 10);",
+ " entries.forEach(([key, value], index) => {",
+ " pm.expect(key).to.eql(`FIELD_${index}`);",
+ " pm.expect(value.type).to.eql('STRING');",
+ " pm.expect(value.value).to.be.a('string');",
+ " });",
+ " });",
+ "}"
+ ]
+ }
+ }
+ ],
+ "item": [
+ {
+ "name": "01 - Consultar version",
+ "request": {
+ "method": "GET",
+ "header": [
+ { "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" },
+ { "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" },
+ { "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" },
+ { "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" }
+ ],
+ "url": "{{base_url}}/versions/current",
+ "description": "Primera llamada de Gestiona. Obtiene la version del estandar soportada por el conector."
+ },
+ "response": []
+ },
+ {
+ "name": "02 - Consultar bookmarks",
+ "request": {
+ "method": "GET",
+ "header": [
+ { "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" },
+ { "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" },
+ { "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" },
+ { "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" }
+ ],
+ "url": "{{base_url}}/",
+ "description": "Segunda llamada de Gestiona. Recupera la URL navegable de genericoperations."
+ },
+ "response": []
+ },
+ {
+ "name": "03 - Consultar datos de denuncia",
+ "request": {
+ "method": "POST",
+ "header": [
+ { "key": "Content-Type", "value": "application/vnd.generic-operation-request+json", "type": "text" },
+ { "key": "X-Rest-Basic-Token", "value": "{{ope_jwt}}", "type": "text" },
+ { "key": "X-Organization-ID", "value": "{{organization_id}}", "type": "text" },
+ { "key": "X-Organization-DIR3", "value": "{{organization_dir3}}", "type": "text" },
+ { "key": "X-Organization-CIF", "value": "{{organization_cif}}", "type": "text" }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"data\": {\n \"FIELD_0\": {\n \"type\": \"STRING\",\n \"value\": \"{{expediente_gestiona}}\"\n }\n }\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ },
+ "url": "{{base_url}}/genericoperations",
+ "description": "Tercera llamada de Gestiona. FIELD_0 contiene el numero de expediente, por ejemplo 53/2026."
+ },
+ "response": []
+ }
+ ],
+ "variable": [
+ { "key": "ope_version", "value": "1.0", "type": "string" },
+ { "key": "ope_jwt", "value": "", "type": "string" },
+ { "key": "ope_uuid", "value": "", "type": "string" },
+ { "key": "ope_transaction_id", "value": "", "type": "string" }
+ ]
+}
diff --git a/Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt b/Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt
new file mode 100644
index 0000000..273f03d
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Postman/INSTRUCCIONES-PRUEBA.txt
@@ -0,0 +1,43 @@
+PRUEBA API OPE DESDE POSTMAN
+============================
+
+Contenido
+---------
+- ApiOPE-Simulacion-Gestiona.postman_collection.json
+- ApiOPE-PRE.postman_environment.json
+
+Importacion
+-----------
+1. Abrir Postman y pulsar Import.
+2. Importar los dos archivos JSON incluidos en este paquete.
+3. Seleccionar el entorno "ApiOPE PRE" en la esquina superior derecha.
+
+Variables que debe rellenar cada probador
+-----------------------------------------
+- base_url: direccion publicada de ApiOPE. El paquete propone http://158.158.42.110:7094.
+- client_token: token de cliente configurado en ApiOPE.
+- secret_key: secreto compartido usado para firmar el JWT HS256.
+- organization_id: identificador de la organizacion remitente.
+- organization_dir3: codigo DIR3 de la organizacion remitente.
+- organization_cif: CIF de la organizacion remitente.
+- expediente_gestiona: numero de un expediente de Gestiona que exista y cuyos datos diarios sigan disponibles, por ejemplo 53/2026.
+
+Las credenciales no se incluyen en este paquete. Deben facilitarse por un canal seguro y guardarse solo como valores locales del entorno de Postman.
+
+Ejecucion
+---------
+Ejecutar las peticiones de la coleccion en este orden:
+
+1. 01 - Consultar version
+2. 02 - Consultar bookmarks
+3. 03 - Consultar datos de denuncia
+
+Las tres peticiones comparten automaticamente el identificador de transaccion. La coleccion genera el JWT de autenticacion y valida el codigo HTTP, el Content-Type y la cabecera Signature de cada respuesta.
+
+Resultado esperado
+------------------
+- Las tres respuestas devuelven HTTP 200.
+- Los tests de Postman aparecen en verde.
+- La tercera respuesta contiene los campos disponibles del expediente solicitado.
+
+Si la peticion no llega al servidor, comprobar que el equipo tiene conectividad con 158.158.42.110 y acceso al puerto 7094.
diff --git a/Antifraude.Net/ApiOPE/Program.cs b/Antifraude.Net/ApiOPE/Program.cs
new file mode 100644
index 0000000..92523ee
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Program.cs
@@ -0,0 +1,72 @@
+using System.Net.Http.Headers;
+using ApiOPE.Configuration;
+using ApiOPE.Security;
+using ApiOPE.Services;
+using Microsoft.Extensions.Options;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Configuration.AddJsonFile(
+ $"appsettings.{builder.Environment.EnvironmentName}.local.json",
+ optional: true,
+ reloadOnChange: false);
+
+var allowHttpPublicBaseUrl = builder.Environment.IsDevelopment() ||
+ builder.Environment.IsEnvironment("PreproductionTest");
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+builder.Services.AddSingleton(TimeProvider.System);
+
+builder.Services
+ .AddOptions()
+ .Bind(builder.Configuration.GetSection(OpeOptions.SectionName))
+ .Validate(
+ options => OpeOptionsValidator.IsValid(options, allowHttpPublicBaseUrl),
+ "La configuracion Ope no es valida. Revisa client token, secret, URL publica, organizacion y campos de salida.")
+ .ValidateOnStart();
+
+builder.Services
+ .AddOptions()
+ .Bind(builder.Configuration.GetSection(InternalApiOptions.SectionName))
+ .Validate(
+ InternalApiOptionsValidator.IsValid,
+ "La configuracion InternalApi no es valida. Revisa BaseUrl, ApiKey y TimeoutSeconds.")
+ .ValidateOnStart();
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+builder.Services.AddHttpClient((services, client) =>
+{
+ var options = services.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
+ client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
+ client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ client.DefaultRequestHeaders.TryAddWithoutValidation(options.ApiKeyHeaderName, options.ApiKey);
+});
+
+var app = builder.Build();
+
+app.UseExceptionHandler(exceptionApp => exceptionApp.Run(OpeExceptionHandler.HandleAsync));
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+if (builder.Configuration.GetValue("ForceHttpsRedirection", false))
+{
+ app.UseHttpsRedirection();
+}
+
+app.UseRouting();
+app.UseMiddleware();
+app.MapControllers();
+
+app.Run();
+
+public partial class Program;
diff --git a/Antifraude.Net/ApiOPE/Properties/launchSettings.json b/Antifraude.Net/ApiOPE/Properties/launchSettings.json
new file mode 100644
index 0000000..98dc0af
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:32166",
+ "sslPort": 44376
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5090",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7123;http://localhost:5090",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/README.md b/Antifraude.Net/ApiOPE/README.md
new file mode 100644
index 0000000..62a9eb2
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/README.md
@@ -0,0 +1,153 @@
+# ApiOPE
+
+`ApiOPE` es el conector externo de operaciones genericas de Gestiona. Expone
+unicamente el contrato OPE 1.0 y consulta los datos en `ApiDenuncias`, que debe
+permanecer en la red interna.
+
+## Flujo
+
+1. Gestiona consulta `GET /versions/current`.
+2. Gestiona consulta `GET /` y obtiene el bookmark de la operacion generica.
+3. Gestiona llama a `POST /genericoperations` con el numero de expediente
+ (`53/2026`) en `FIELD_0`. Para pruebas tambien se admite el identificador de
+ denuncia (`116`).
+4. `ApiOPE` consulta el endpoint protegido correspondiente de `ApiDenuncias`.
+5. `ApiOPE` transforma la respuesta interna al formato `FIELD_0`...`FIELD_9` y
+ la devuelve a Gestiona.
+
+Solo el puerto o FQDN de `ApiOPE` debe publicarse. `ApiDenuncias` debe quedar
+limitada a la red interna o a localhost y sus endpoints OPE exigen, ademas, la
+cabecera compartida `X-ApiOPE-Key`.
+
+## Contrato externo
+
+| Metodo | Ruta | Content-Type de respuesta |
+| --- | --- | --- |
+| GET | `/versions/current` | `application/vnd.version+json` |
+| GET | `/` | `application/vnd.bookmark-list+json` |
+| POST | `/genericoperations` | `application/vnd.generic-operation-response+json` |
+
+Las tres peticiones requieren `X-Rest-Basic-Token`, `X-Organization-ID`,
+`X-Organization-DIR3` y `X-Organization-CIF`. El JWT se valida con HS256,
+incluyendo `client_token`, `resource`, `uuid`, `timestamp`, `version` cuando
+corresponde y `transaction_id`. Las respuestas autenticadas incluyen la
+cabecera `Signature` calculada con el algoritmo exigido por Gestiona.
+
+Los errores de datos, elemento inexistente o fallo del conector se devuelven
+como HTTP 412 con `application/vnd.generic-operation.error+json`. Una firma o
+identidad de conector invalida devuelve HTTP 401 con causa `WRONG_SIGNATURE`.
+
+## Configuracion de despliegue
+
+Los secretos no deben escribirse en los ficheros versionados. Configurar estas
+claves como variables de entorno del proceso, ajustes de IIS/App Service o
+secretos equivalentes:
+
+```text
+Ope__ClientToken
+Ope__SecretKey
+Ope__PublicBaseUrl
+Ope__OrganizationId
+Ope__OrganizationDir3
+Ope__OrganizationCif
+Ope__TokenMaxAgeSeconds
+Ope__ClockSkewSeconds
+Ope__OutputFields__0 ... Ope__OutputFields__9
+
+InternalApi__BaseUrl
+InternalApi__ApiKey
+InternalApi__ApiKeyHeaderName=X-ApiOPE-Key
+InternalApi__TimeoutSeconds
+```
+
+En `ApiDenuncias`, configurar la misma clave interna:
+
+```text
+OpeBridge__ApiKey=
+OpeBridge__ApiKeyHeaderName=X-ApiOPE-Key
+```
+
+`Ope__PublicBaseUrl` debe ser la URL HTTPS publica completa del conector,
+incluido cualquier path base. El bookmark se construye a partir de ese valor.
+
+La aplicacion valida la configuracion al arrancar. Si faltan el secreto, los
+datos de organizacion, la URL publica o el mapa de salida, no inicia para evitar
+publicar un conector incompleto.
+
+### Prueba temporal de preproduccion
+
+Los perfiles IIS de `ApiOPE` y `ApiDenuncias` usan temporalmente el entorno
+`PreproductionTest` para probar el conector directamente por IP y HTTP. Sus
+credenciales se guardan en estos archivos locales:
+
+```text
+ApiOPE/appsettings.PreproductionTest.local.json
+ApiDenuncias/appsettings.PreproductionTest.local.json
+```
+
+Ambos archivos estan excluidos de Git, pero se copian a los paquetes generados
+en esta maquina. La clave `InternalApi__ApiKey` de `ApiOPE` debe coincidir con
+`OpeBridge__ApiKey` de `ApiDenuncias`.
+
+Este perfil no debe utilizarse como configuracion definitiva. Al crear el
+conector real en Gestiona se deben configurar por un canal seguro su codigo,
+su `Secret Key` y los datos reales de organizacion, publicar la URL HTTPS y
+volver a usar un entorno de produccion.
+
+## Campos de salida
+
+La API interna ofrece estos 12 campos:
+
+```text
+fechaDenuncia
+numeroDenunciaCanal
+aQuienDenuncia
+resumenDenuncia
+fechaHechos
+lugarHechos
+ambitoCompetencias
+solicitaProteccion
+sexoDenunciante
+autorizaRemisionDenuncia
+autorizaNotificacionesViaSms
+preferenciaNotificacionSeguimientoDenuncia
+```
+
+OPE 1.0 admite como maximo 10 campos por operacion. Antes de configurar PRE hay
+que acordar cuales diez se publican o dividir la consulta en dos operaciones.
+El orden de `Ope__OutputFields__N` determina la correspondencia con `FIELD_N`.
+
+## Publicacion
+
+1. Publicar `ApiDenuncias` con `OpeBridge__ApiKey` configurada.
+2. Publicar `ApiOPE` con los valores anteriores y una clave interna distinta de
+ `Ope__SecretKey`.
+3. Permitir desde `ApiOPE` la conexion interna a `ApiDenuncias` y bloquear el
+ acceso exterior directo a esta ultima.
+4. Publicar exclusivamente `ApiOPE` mediante HTTPS y configurar su URL base en
+ el conector de Gestiona.
+5. Validar consecutivamente version, bookmarks y operacion generica, incluida
+ la cabecera `Signature` de cada respuesta.
+
+Los logs identifican fallos mediante `transaction_id`, pero nunca registran el
+JWT, el secreto compartido, la clave interna ni el contenido de la denuncia.
+
+## Simulacion desde Postman
+
+En `Postman` se incluyen una coleccion y un entorno importables:
+
+```text
+Postman/ApiOPE-Simulacion-Gestiona.postman_collection.json
+Postman/ApiOPE-PRE.postman_environment.json
+```
+
+1. Importar ambos ficheros en Postman y seleccionar el entorno `ApiOPE PRE`.
+2. Rellenar sus siete variables con la misma URL, identidad y secreto que tenga
+ el conector publicado. El expediente debe corresponder a datos del dia que
+ aun no hayan sido purgados.
+3. Ejecutar la coleccion completa con Runner para conservar un mismo
+ `transaction_id` durante las tres peticiones.
+
+La coleccion genera un `uuid` y un JWT HS256 nuevos en cada llamada. Tambien
+comprueba automaticamente el status, el media type, el cuerpo y la cabecera
+`Signature` devuelta por `ApiOPE`.
diff --git a/Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs b/Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs
new file mode 100644
index 0000000..642abe6
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Security/OpeAuthenticatedAttribute.cs
@@ -0,0 +1,15 @@
+namespace ApiOPE.Security;
+
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
+public sealed class OpeAuthenticatedAttribute : Attribute
+{
+ public OpeAuthenticatedAttribute(string resource, bool versionOptional = false)
+ {
+ Resource = resource;
+ VersionOptional = versionOptional;
+ }
+
+ public string Resource { get; }
+
+ public bool VersionOptional { get; }
+}
diff --git a/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs b/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs
new file mode 100644
index 0000000..3b9502e
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Security/OpeAuthenticationMiddleware.cs
@@ -0,0 +1,131 @@
+using System.Security.Claims;
+using ApiOPE.Configuration;
+using ApiOPE.Contracts;
+using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Primitives;
+
+namespace ApiOPE.Security;
+
+public sealed class OpeAuthenticationMiddleware
+{
+ public const string TokenHeaderName = "X-Rest-Basic-Token";
+ public const string OrganizationIdHeaderName = "X-Organization-ID";
+ public const string OrganizationDir3HeaderName = "X-Organization-DIR3";
+ public const string OrganizationCifHeaderName = "X-Organization-CIF";
+
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public OpeAuthenticationMiddleware(
+ RequestDelegate next,
+ ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(
+ HttpContext context,
+ OpeTokenValidator tokenValidator,
+ OpeResponseSigner responseSigner,
+ IOptions options)
+ {
+ var authentication = context.GetEndpoint()?.Metadata.GetMetadata();
+ if (authentication is null)
+ {
+ await _next(context);
+ return;
+ }
+
+ if (!TryGetSingleHeader(context.Request.Headers, TokenHeaderName, out var token))
+ {
+ await RejectAsync(context, "Falta la cabecera de autenticacion OPE.");
+ return;
+ }
+
+ var validation = tokenValidator.Validate(token, authentication.Resource, authentication.VersionOptional);
+ if (!validation.IsValid || validation.RequestContext is null)
+ {
+ await RejectAsync(context, validation.FailureReason);
+ return;
+ }
+
+ if (!ValidateOrganizationHeaders(context.Request.Headers, options.Value, out var organizationFailure))
+ {
+ await RejectAsync(context, organizationFailure);
+ return;
+ }
+
+ OpeRequestContextStore.Set(context, validation.RequestContext);
+ context.User = new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim("client_token", validation.RequestContext.ClientToken),
+ new Claim("transaction_id", validation.RequestContext.TransactionId.ToString()),
+ new Claim("uuid", validation.RequestContext.Uuid.ToString())
+ ], "OpeJwt"));
+
+ context.Response.OnStarting(() =>
+ {
+ context.Response.Headers["Signature"] = responseSigner.Sign(validation.RequestContext.Uuid);
+ context.Response.Headers.CacheControl = "no-store";
+ context.Response.Headers.Pragma = "no-cache";
+ context.Response.Headers["X-Content-Type-Options"] = "nosniff";
+ return Task.CompletedTask;
+ });
+
+ await _next(context);
+ }
+
+ private async Task RejectAsync(HttpContext context, string reason)
+ {
+ _logger.LogWarning(
+ "Peticion OPE rechazada en {Method} {Path}. Motivo={Reason}",
+ context.Request.Method,
+ context.Request.Path,
+ reason);
+
+ await OpeResponseWriter.WriteErrorAsync(
+ context.Response,
+ StatusCodes.Status401Unauthorized,
+ "Unauthorized",
+ OpeErrorCauses.WrongSignature,
+ null,
+ context.RequestAborted);
+ }
+
+ private static bool ValidateOrganizationHeaders(
+ IHeaderDictionary headers,
+ OpeOptions options,
+ out string failureReason)
+ {
+ var valid = MatchesHeader(headers, OrganizationIdHeaderName, options.OrganizationId) &&
+ MatchesHeader(headers, OrganizationDir3HeaderName, options.OrganizationDir3) &&
+ MatchesHeader(headers, OrganizationCifHeaderName, options.OrganizationCif);
+
+ failureReason = valid
+ ? string.Empty
+ : "Las cabeceras de organizacion no corresponden al conector configurado.";
+ return valid;
+ }
+
+ private static bool MatchesHeader(IHeaderDictionary headers, string name, string expected)
+ {
+ return TryGetSingleHeader(headers, name, out var value) &&
+ string.Equals(value, expected, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool TryGetSingleHeader(
+ IHeaderDictionary headers,
+ string name,
+ out string value)
+ {
+ value = string.Empty;
+ if (!headers.TryGetValue(name, out StringValues values) || values.Count != 1)
+ {
+ return false;
+ }
+
+ value = values[0]?.Trim() ?? string.Empty;
+ return !string.IsNullOrWhiteSpace(value);
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs b/Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs
new file mode 100644
index 0000000..41ae9ed
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Security/OpeExceptionHandler.cs
@@ -0,0 +1,31 @@
+using ApiOPE.Contracts;
+using Microsoft.AspNetCore.Diagnostics;
+
+namespace ApiOPE.Security;
+
+public static class OpeExceptionHandler
+{
+ public static async Task HandleAsync(HttpContext context)
+ {
+ var exception = context.Features.Get()?.Error;
+ var requestContext = OpeRequestContextStore.Get(context);
+ var logger = context.RequestServices
+ .GetRequiredService()
+ .CreateLogger("ApiOPE.UnhandledException");
+
+ logger.LogError(
+ exception,
+ "Error no controlado en la operacion OPE. Path={Path}; TransactionId={TransactionId}",
+ context.Request.Path,
+ requestContext?.TransactionId);
+
+ context.Response.Clear();
+ await OpeResponseWriter.WriteErrorAsync(
+ context.Response,
+ StatusCodes.Status412PreconditionFailed,
+ "Error en el conector",
+ OpeErrorCauses.ConnectorError,
+ null,
+ context.RequestAborted);
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs b/Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs
new file mode 100644
index 0000000..4c0e418
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Security/OpeRequestContext.cs
@@ -0,0 +1,20 @@
+namespace ApiOPE.Security;
+
+public sealed record OpeRequestContext(
+ string ClientToken,
+ string Resource,
+ string Uuid,
+ Guid TransactionId,
+ long Timestamp,
+ string? Version);
+
+public static class OpeRequestContextStore
+{
+ private static readonly object Key = new();
+
+ public static void Set(HttpContext context, OpeRequestContext requestContext)
+ => context.Items[Key] = requestContext;
+
+ public static OpeRequestContext? Get(HttpContext context)
+ => context.Items.TryGetValue(Key, out var value) ? value as OpeRequestContext : null;
+}
diff --git a/Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs b/Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs
new file mode 100644
index 0000000..6a800bc
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Security/OpeResponseSigner.cs
@@ -0,0 +1,23 @@
+using System.Security.Cryptography;
+using System.Text;
+using ApiOPE.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace ApiOPE.Security;
+
+public sealed class OpeResponseSigner
+{
+ private readonly byte[] _secretKey;
+
+ public OpeResponseSigner(IOptions options)
+ {
+ _secretKey = Encoding.UTF8.GetBytes(options.Value.SecretKey);
+ }
+
+ public string Sign(string uuid)
+ {
+ var hash = HMACSHA256.HashData(_secretKey, Encoding.UTF8.GetBytes(uuid));
+ var lowercaseHex = Convert.ToHexString(hash).ToLowerInvariant();
+ return Convert.ToBase64String(Encoding.UTF8.GetBytes(lowercaseHex));
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs b/Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs
new file mode 100644
index 0000000..e82e370
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Security/OpeResponseWriter.cs
@@ -0,0 +1,33 @@
+using System.Text.Json;
+using ApiOPE.Contracts;
+
+namespace ApiOPE.Security;
+
+public static class OpeResponseWriter
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+
+ public static async Task WriteErrorAsync(
+ HttpResponse response,
+ int statusCode,
+ string message,
+ string cause,
+ IReadOnlyDictionary? data,
+ CancellationToken cancellationToken = default)
+ {
+ response.StatusCode = statusCode;
+ response.ContentType = OpeMediaTypes.Error;
+
+ var error = new OpeErrorResponse(
+ statusCode,
+ message,
+ new OpeTypedInfo(null, null, null, cause),
+ data);
+
+ await JsonSerializer.SerializeAsync(
+ response.Body,
+ error,
+ JsonOptions,
+ cancellationToken);
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs b/Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs
new file mode 100644
index 0000000..917a047
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Security/OpeTokenValidator.cs
@@ -0,0 +1,238 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using ApiOPE.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace ApiOPE.Security;
+
+public sealed record OpeTokenValidationResult(
+ bool IsValid,
+ OpeRequestContext? RequestContext,
+ string FailureReason)
+{
+ public static OpeTokenValidationResult Success(OpeRequestContext context)
+ => new(true, context, string.Empty);
+
+ public static OpeTokenValidationResult Failure(string reason)
+ => new(false, null, reason);
+}
+
+public sealed class OpeTokenValidator
+{
+ private const int MaxTokenLength = 16 * 1024;
+
+ private readonly OpeOptions _options;
+ private readonly TimeProvider _timeProvider;
+ private readonly byte[] _secretKey;
+
+ public OpeTokenValidator(IOptions options, TimeProvider timeProvider)
+ {
+ _options = options.Value;
+ _timeProvider = timeProvider;
+ _secretKey = Encoding.UTF8.GetBytes(_options.SecretKey);
+ }
+
+ public OpeTokenValidationResult Validate(
+ string token,
+ string expectedResource,
+ bool versionOptional)
+ {
+ if (string.IsNullOrWhiteSpace(token) || token.Length > MaxTokenLength)
+ {
+ return OpeTokenValidationResult.Failure("Token ausente o demasiado largo.");
+ }
+
+ var segments = token.Split('.');
+ if (segments.Length != 3 || segments.Any(string.IsNullOrWhiteSpace))
+ {
+ return OpeTokenValidationResult.Failure("El JWT no contiene tres segmentos.");
+ }
+
+ byte[] headerBytes;
+ byte[] payloadBytes;
+ byte[] receivedSignature;
+ try
+ {
+ headerBytes = DecodeBase64Url(segments[0]);
+ payloadBytes = DecodeBase64Url(segments[1]);
+ receivedSignature = DecodeBase64Url(segments[2]);
+ }
+ catch (FormatException)
+ {
+ return OpeTokenValidationResult.Failure("El JWT no esta codificado en Base64Url valido.");
+ }
+
+ if (!HasSupportedHeader(headerBytes))
+ {
+ return OpeTokenValidationResult.Failure("El JWT no declara exclusivamente HS256.");
+ }
+
+ var signedBytes = Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}");
+ var expectedSignature = HMACSHA256.HashData(_secretKey, signedBytes);
+ if (receivedSignature.Length != expectedSignature.Length ||
+ !CryptographicOperations.FixedTimeEquals(receivedSignature, expectedSignature))
+ {
+ return OpeTokenValidationResult.Failure("La firma del JWT no es valida.");
+ }
+
+ try
+ {
+ using var payload = JsonDocument.Parse(payloadBytes, new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = 8
+ });
+
+ if (payload.RootElement.ValueKind != JsonValueKind.Object ||
+ HasDuplicateProperties(payload.RootElement))
+ {
+ return OpeTokenValidationResult.Failure("El payload del JWT no es un objeto valido.");
+ }
+
+ var clientToken = GetRequiredString(payload.RootElement, "client_token");
+ var resource = GetRequiredString(payload.RootElement, "resource");
+ var uuidText = GetRequiredString(payload.RootElement, "uuid");
+ var transactionIdText = GetRequiredString(payload.RootElement, "transaction_id");
+ var version = GetOptionalString(payload.RootElement, "version");
+ var timestamp = GetRequiredInt64(payload.RootElement, "timestamp");
+
+ if (!FixedTimeEquals(clientToken, _options.ClientToken))
+ {
+ return OpeTokenValidationResult.Failure("El client_token no corresponde al conector.");
+ }
+
+ if (!string.Equals(resource, expectedResource, StringComparison.Ordinal))
+ {
+ return OpeTokenValidationResult.Failure("El recurso firmado no corresponde al recurso solicitado.");
+ }
+
+ if ((!versionOptional && !string.Equals(version, _options.Version, StringComparison.Ordinal)) ||
+ (versionOptional && version is not null && !string.Equals(version, _options.Version, StringComparison.Ordinal)))
+ {
+ return OpeTokenValidationResult.Failure("La version firmada no es compatible.");
+ }
+
+ if (!Guid.TryParseExact(uuidText, "D", out _) ||
+ !Guid.TryParseExact(transactionIdText, "D", out var transactionId))
+ {
+ return OpeTokenValidationResult.Failure("uuid o transaction_id no tienen formato UUID.");
+ }
+
+ var now = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
+ if (timestamp > now + _options.ClockSkewSeconds ||
+ timestamp < now - _options.TokenMaxAgeSeconds - _options.ClockSkewSeconds)
+ {
+ return OpeTokenValidationResult.Failure("El timestamp del JWT esta fuera de la ventana permitida.");
+ }
+
+ return OpeTokenValidationResult.Success(new OpeRequestContext(
+ clientToken,
+ resource,
+ uuidText,
+ transactionId,
+ timestamp,
+ version));
+ }
+ catch (JsonException)
+ {
+ return OpeTokenValidationResult.Failure("El payload del JWT no contiene JSON valido.");
+ }
+ catch (InvalidOperationException exception)
+ {
+ return OpeTokenValidationResult.Failure(exception.Message);
+ }
+ }
+
+ private static bool HasSupportedHeader(byte[] headerBytes)
+ {
+ try
+ {
+ using var header = JsonDocument.Parse(headerBytes, new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = 4
+ });
+
+ return header.RootElement.ValueKind == JsonValueKind.Object &&
+ !HasDuplicateProperties(header.RootElement) &&
+ string.Equals(GetRequiredString(header.RootElement, "alg"), "HS256", StringComparison.Ordinal);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
+ private static byte[] DecodeBase64Url(string value)
+ {
+ var base64 = value.Replace('-', '+').Replace('_', '/');
+ base64 = (base64.Length % 4) switch
+ {
+ 0 => base64,
+ 2 => base64 + "==",
+ 3 => base64 + "=",
+ _ => throw new FormatException("Longitud Base64Url no valida.")
+ };
+
+ return Convert.FromBase64String(base64);
+ }
+
+ private static bool HasDuplicateProperties(JsonElement element)
+ {
+ var names = new HashSet(StringComparer.Ordinal);
+ return element.EnumerateObject().Any(property => !names.Add(property.Name));
+ }
+
+ private static string GetRequiredString(JsonElement element, string name)
+ {
+ if (!element.TryGetProperty(name, out var property) ||
+ property.ValueKind != JsonValueKind.String ||
+ string.IsNullOrWhiteSpace(property.GetString()))
+ {
+ throw new InvalidOperationException($"Falta la claim obligatoria {name}.");
+ }
+
+ return property.GetString()!;
+ }
+
+ private static string? GetOptionalString(JsonElement element, string name)
+ {
+ if (!element.TryGetProperty(name, out var property))
+ {
+ return null;
+ }
+
+ if (property.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(property.GetString()))
+ {
+ throw new InvalidOperationException($"La claim {name} tiene un formato incorrecto.");
+ }
+
+ return property.GetString();
+ }
+
+ private static long GetRequiredInt64(JsonElement element, string name)
+ {
+ if (!element.TryGetProperty(name, out var property) ||
+ property.ValueKind != JsonValueKind.Number ||
+ !property.TryGetInt64(out var value))
+ {
+ throw new InvalidOperationException($"Falta la claim numerica obligatoria {name}.");
+ }
+
+ return value;
+ }
+
+ private static bool FixedTimeEquals(string left, string right)
+ {
+ var leftHash = SHA256.HashData(Encoding.UTF8.GetBytes(left));
+ var rightHash = SHA256.HashData(Encoding.UTF8.GetBytes(right));
+ return CryptographicOperations.FixedTimeEquals(leftHash, rightHash);
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs b/Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs
new file mode 100644
index 0000000..f9df417
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Services/DenunciaLookup.cs
@@ -0,0 +1,38 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace ApiOPE.Services;
+
+public sealed record DenunciaLookup(string RelativePath);
+
+public static partial class DenunciaLookupParser
+{
+ public static bool TryParse(string value, out DenunciaLookup? lookup)
+ {
+ lookup = null;
+ var trimmed = value.Trim();
+
+ var expedienteMatch = ExpedientePattern().Match(trimmed);
+ if (expedienteMatch.Success &&
+ int.TryParse(expedienteMatch.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var number) &&
+ int.TryParse(expedienteMatch.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var year) &&
+ number > 0 &&
+ year is >= 2000 and <= 9999)
+ {
+ lookup = new DenunciaLookup($"api/denuncias/{number}/{year}/gestiona-fields");
+ return true;
+ }
+
+ if (int.TryParse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture, out var complaintId) &&
+ complaintId > 0)
+ {
+ lookup = new DenunciaLookup($"api/denuncias/{complaintId}/gestiona-fields");
+ return true;
+ }
+
+ return false;
+ }
+
+ [GeneratedRegex(@"^(\d+)\s*/\s*(\d{4})$", RegexOptions.CultureInvariant)]
+ private static partial Regex ExpedientePattern();
+}
diff --git a/Antifraude.Net/ApiOPE/Services/GenericOperationRequestReader.cs b/Antifraude.Net/ApiOPE/Services/GenericOperationRequestReader.cs
new file mode 100644
index 0000000..1cfc9ea
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Services/GenericOperationRequestReader.cs
@@ -0,0 +1,93 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ApiOPE.Contracts;
+
+namespace ApiOPE.Services;
+
+public sealed record GenericOperationRequestResult(
+ OpeDataEnvelope? Request,
+ IReadOnlyDictionary Errors)
+{
+ public bool IsValid => Request is not null && Errors.Count == 0;
+}
+
+public static class GenericOperationRequestReader
+{
+ private const string ExpectedField = "FIELD_0";
+
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = false,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ MaxDepth = 16
+ };
+
+ public static async Task ReadAsync(
+ HttpRequest request,
+ CancellationToken cancellationToken)
+ {
+ var errors = new Dictionary(StringComparer.Ordinal);
+
+ if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var contentType) ||
+ !string.Equals(
+ contentType.MediaType,
+ OpeMediaTypes.GenericOperationRequest,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ errors[ExpectedField] = OpeFieldErrors.UnexpectedFormat;
+ return new GenericOperationRequestResult(null, errors);
+ }
+
+ OpeDataEnvelope? envelope;
+ try
+ {
+ envelope = await JsonSerializer.DeserializeAsync(
+ request.Body,
+ JsonOptions,
+ cancellationToken);
+ }
+ catch (JsonException)
+ {
+ errors[ExpectedField] = OpeFieldErrors.UnexpectedFormat;
+ return new GenericOperationRequestResult(null, errors);
+ }
+
+ if (envelope?.Data is null)
+ {
+ errors[ExpectedField] = OpeFieldErrors.Expected;
+ return new GenericOperationRequestResult(null, errors);
+ }
+
+ foreach (var field in envelope.Data)
+ {
+ if (!string.Equals(field.Key, ExpectedField, StringComparison.Ordinal))
+ {
+ errors[field.Key] = OpeFieldErrors.NotExpected;
+ continue;
+ }
+
+ if (field.Value is null ||
+ !string.Equals(field.Value.Type, "STRING", StringComparison.Ordinal) ||
+ string.IsNullOrWhiteSpace(field.Value.Value))
+ {
+ errors[field.Key] = OpeFieldErrors.UnexpectedFormat;
+ }
+ }
+
+ if (!envelope.Data.ContainsKey(ExpectedField))
+ {
+ errors[ExpectedField] = OpeFieldErrors.Expected;
+ }
+
+ if (envelope.Data.Count > 10)
+ {
+ foreach (var field in envelope.Data.Keys.Where(key => key != ExpectedField))
+ {
+ errors[field] = OpeFieldErrors.NotExpected;
+ }
+ }
+
+ return new GenericOperationRequestResult(envelope, errors);
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs b/Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs
new file mode 100644
index 0000000..957abec
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Services/InternalDenunciasClient.cs
@@ -0,0 +1,98 @@
+using System.Net;
+using System.Text.Json;
+using ApiOPE.Contracts;
+
+namespace ApiOPE.Services;
+
+public enum InternalLookupStatus
+{
+ Success,
+ NotFound
+}
+
+public sealed record InternalLookupResult(
+ InternalLookupStatus Status,
+ InternalGestionaFieldsResponse? Fields);
+
+public sealed class InternalApiException : Exception
+{
+ public InternalApiException(string message)
+ : base(message)
+ {
+ }
+
+ public InternalApiException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
+
+public sealed class InternalDenunciasClient
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = false,
+ MaxDepth = 16
+ };
+
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ public InternalDenunciasClient(
+ HttpClient httpClient,
+ ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ public async Task GetFieldsAsync(
+ DenunciaLookup lookup,
+ Guid transactionId,
+ CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, lookup.RelativePath);
+ using var response = await _httpClient.SendAsync(
+ request,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken);
+
+ if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
+ {
+ return new InternalLookupResult(InternalLookupStatus.NotFound, null);
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogWarning(
+ "ApiDenuncias ha rechazado una consulta OPE. Status={StatusCode}; TransactionId={TransactionId}",
+ (int)response.StatusCode,
+ transactionId);
+ throw new InternalApiException("ApiDenuncias no ha podido completar la consulta OPE.");
+ }
+
+ try
+ {
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ var fields = await JsonSerializer.DeserializeAsync(
+ stream,
+ JsonOptions,
+ cancellationToken);
+
+ if (fields?.Data is null)
+ {
+ throw new InvalidDataException("La respuesta interna no contiene el objeto data.");
+ }
+
+ return new InternalLookupResult(InternalLookupStatus.Success, fields);
+ }
+ catch (JsonException exception)
+ {
+ throw new InternalApiException("ApiDenuncias ha devuelto una respuesta no valida.", exception);
+ }
+ catch (InvalidDataException exception)
+ {
+ throw new InternalApiException("ApiDenuncias ha devuelto una respuesta incompleta.", exception);
+ }
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs b/Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs
new file mode 100644
index 0000000..ced8f62
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/Services/OpeFieldMapper.cs
@@ -0,0 +1,51 @@
+using ApiOPE.Configuration;
+using ApiOPE.Contracts;
+using Microsoft.Extensions.Options;
+
+namespace ApiOPE.Services;
+
+public sealed class OpeFieldMapper
+{
+ public static readonly IReadOnlySet SupportedInternalFields = new HashSet(
+ [
+ "fechaDenuncia",
+ "numeroDenunciaCanal",
+ "aQuienDenuncia",
+ "resumenDenuncia",
+ "fechaHechos",
+ "lugarHechos",
+ "ambitoCompetencias",
+ "solicitaProteccion",
+ "sexoDenunciante",
+ "autorizaRemisionDenuncia",
+ "autorizaNotificacionesViaSms",
+ "preferenciaNotificacionSeguimientoDenuncia"
+ ],
+ StringComparer.Ordinal);
+
+ private readonly string[] _outputFields;
+
+ public OpeFieldMapper(IOptions options)
+ {
+ _outputFields = options.Value.OutputFields.ToArray();
+ }
+
+ public OpeDataEnvelope Map(InternalGestionaFieldsResponse source)
+ {
+ var output = new Dictionary(StringComparer.Ordinal);
+
+ for (var index = 0; index < _outputFields.Length; index++)
+ {
+ var internalField = _outputFields[index];
+ if (!source.Data.TryGetValue(internalField, out var value))
+ {
+ throw new InvalidDataException(
+ $"La API interna no ha devuelto el campo configurado '{internalField}'.");
+ }
+
+ output[$"FIELD_{index}"] = new OpeFieldValue("STRING", value.Value?.Trim() ?? string.Empty);
+ }
+
+ return new OpeDataEnvelope(output);
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/appsettings.Development.json b/Antifraude.Net/ApiOPE/appsettings.Development.json
new file mode 100644
index 0000000..df35d6a
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/appsettings.Development.json
@@ -0,0 +1,37 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Ope": {
+ "Version": "1.0",
+ "ClientToken": "development-ope-client",
+ "SecretKey": "development-only-ope-secret-key-change-me",
+ "PublicBaseUrl": "http://localhost:5090",
+ "TokenMaxAgeSeconds": 300,
+ "ClockSkewSeconds": 60,
+ "OrganizationId": "00000000-0000-0000-0000-000000000001",
+ "OrganizationDir3": "DEV000001",
+ "OrganizationCif": "DEV000001",
+ "OutputFields": [
+ "fechaDenuncia",
+ "numeroDenunciaCanal",
+ "aQuienDenuncia",
+ "resumenDenuncia",
+ "fechaHechos",
+ "lugarHechos",
+ "ambitoCompetencias",
+ "solicitaProteccion",
+ "sexoDenunciante",
+ "autorizaRemisionDenuncia"
+ ]
+ },
+ "InternalApi": {
+ "BaseUrl": "http://localhost:7093",
+ "ApiKey": "development-only-internal-ope-key-change-me",
+ "ApiKeyHeaderName": "X-ApiOPE-Key",
+ "TimeoutSeconds": 20
+ }
+}
diff --git a/Antifraude.Net/ApiOPE/appsettings.json b/Antifraude.Net/ApiOPE/appsettings.json
new file mode 100644
index 0000000..8094dcb
--- /dev/null
+++ b/Antifraude.Net/ApiOPE/appsettings.json
@@ -0,0 +1,28 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ForceHttpsRedirection": false,
+ "Ope": {
+ "Version": "1.0",
+ "ClientToken": "",
+ "SecretKey": "",
+ "PublicBaseUrl": "",
+ "TokenMaxAgeSeconds": 300,
+ "ClockSkewSeconds": 60,
+ "OrganizationId": "",
+ "OrganizationDir3": "",
+ "OrganizationCif": "",
+ "OutputFields": []
+ },
+ "InternalApi": {
+ "BaseUrl": "http://localhost:7093",
+ "ApiKey": "",
+ "ApiKeyHeaderName": "X-ApiOPE-Key",
+ "TimeoutSeconds": 20
+ }
+}