Skip to content
← Todas las publicaciones
· Delta1 Labs KeyrightLicenciamiento.NETAnálisis a fondo

¿Qué estado HTTP merece una comprobación de licencia fallida? 402, 403, Problem Details y una cabecera que el cliente pueda leer

Cuando una API con licencia decide que un llamador no tiene permiso para ejecutarse, lo peor que puede hacer es devolver un 500 pelado con un stack trace, y lo segundo peor es un 200 con { "ok": false } que ningún cliente nota jamás. Esta entrada deduce el contrato HTTP que una puerta de licencia realmente debe a sus llamadores: por qué un fallo de licencia no es un 401 (el llamador está perfectamente autenticado — es la licencia la que caducó), cuándo es correcto el 403 y cuándo el 402 Payment Required dice más, cómo transportar el motivo legible por máquina en un cuerpo application/problem+json según RFC 9457 con miembros de extensión sobre los que un cliente puede ramificar, y la única cabecera de respuesta —nunca la clave— que permite a una CLI o a un servicio hermano reaccionar sin analizar prosa. Construye la puerta como un IEndpointFilter de ASP.NET Core que mapea el LicenseStatus de Keyright sobre códigos de estado mediante IProblemDetailsService, añade Retry-After cuando el servicio de licencias está brevemente inaccesible y la ventana de gracia sigue abierta, y termina con una prueba WebApplicationFactory que afirma el estado, el tipo de problema y que la clave firmada nunca aparece en el cuerpo.

Un servicio con licencia toma una decisión al entrar — ¿tiene este llamador permiso para ejecutarse ahora mismo? — y la respuesta HTTP que envía cuando la respuesta es no casi siempre es una ocurrencia tardía. Los dos fallos habituales son un 500 pelado con un stack trace (que no le dice nada al llamador y filtra tus interioridades) y un 200 OK que lleva { "ok": false } en el cuerpo (que ningún cliente HTTP, proxy o política de reintento notará jamás, porque en lo que respecta a la pila la petición tuvo éxito). Entre ambos está el contrato que la puerta realmente debe a sus llamadores: el código de estado correcto, un motivo legible por máquina y una cabecera sobre la que un cliente pueda ramificar — sin devolver nunca la clave de licencia.

Esta entrada construye ese contrato en torno a una licencia de Keyright. La verificación en sí — comprobar la firma, la caducidad, el vínculo con la máquina — se trata en otro lugar; aquí la licencia ya ha sido verificada y reducida a un LicenseStatus, y la única pregunta es cómo convertir ese estado en una respuesta HTTP que una CLI, un navegador y un servicio hermano puedan manejar todos correctamente.

Un fallo de licencia no es un fallo de autenticación

El reflejo es echar mano del 401 Unauthorized, porque la petición fue «rechazada». Ese es el eje equivocado. 401 significa no sé quién eres; es una señal de autenticación, y la especificación exige que lleve una cabecera WWW-Authenticate que indique al cliente cómo presentar credenciales. Un cliente que recibe un 401 volverá a autenticarse — y si sus credenciales son válidas, lo logrará, reintentará y se estrellará exactamente contra el mismo muro. Has construido un bucle.

Un fallo de licencia es ortogonal a la identidad. El llamador puede estar perfectamente autenticado —un token válido, una API key conocida— y lo que caducó es la licencia. El código de estado tiene que decir «sé exactamente quién eres, y aun así esto no está permitido», que es 403 Forbidden. Eso cubre una clave revocada, un desajuste de máquina o una petición de una característica que la licencia no incluye.

Hay un caso en que el 403 se queda corto. Cuando la licencia simplemente ha caducado — la suscripción se agotó — el remedio no es «pedir permiso a un administrador», es «renovar». Para eso, 402 Payment Required dice más. Se marcó como reservado en la especificación original de HTTP, pero lo dejan pasar limpiamente todos los proxies y lo usan ampliamente hoy las API de suscripción; la única regla es no esperar que un navegador haga nada mágico con él. Emitir un 402 permite a un cliente distinguir un muro comercial de un muro de permisos, que es precisamente la distinción para la que existe una puerta de licencia.

LicenseStatus → estado HTTP + problem+jsonLicenseStatusValidExpiringSoonExpiredRevokedMachineMismatchEntitlementMissingSignatureInvalidServerUnavailable200 OK → se ejecuta el handlerExpiringSoon añade Keyright-License: warning402 Payment Required — renovar403 Forbiddenrevocada · máquina · capacidad· firma inválida503 + Retry-After (gracia abierta)reintenta luego; la clave no tiene nada malTodo cuerpo 4xx/5xx es application/problem+json con una URI de tipo estable y un miembro licenseStatus — y nunca la clave.

El motivo va en un cuerpo problem+json

Un código de estado es un único número; un cliente que quiera mostrar un mensaje útil o ramificar sobre el motivo concreto necesita un detalle estructurado. La forma estándar para eso es RFC 9457 — Problem Details for HTTP APIs, el sucesor del RFC 7807 — servido como application/problem+json:

{
  "type": "https://delta1labs.com/problems/license-expired",
  "title": "License expired",
  "status": 402,
  "detail": "This license expired on 2026-09-30. Renew to continue.",
  "licenseStatus": "Expired",
  "expiresOn": "2026-09-30T00:00:00Z",
  "renewUrl": "https://delta1labs.com/account/renew",
  "traceId": "00-3f1a…-01"
}

La URI type es el contrato. Un cliente ramifica sobre ella —una cadena estable que prometes no cambiar— no sobre el title humano, que eres libre de reformular o localizar. Los miembros bajo detail son miembros de extensión: el RFC 9457 te permite añadir cualquier JSON que quieras, y aquí es donde van los hechos específicos de la licencia. licenseStatus refleja el enum de Keyright, expiresOn le da a una CLI algo que imprimir, renewUrl le da a una UI un botón. Lo que no está ahí es la clave, la firma ni el correo del licenciatario — el cuerpo de error es el artefacto que más se copia en un flujo de soporte, pegado en tickets y capturas, así que se rige por la misma regla que una línea de log.

ASP.NET Core tiene soporte de primera clase para esto. Registra los servicios una vez:

builder.Services.AddProblemDetails();

AddProblemDetails() registra un IProblemDetailsService y conecta el framework para emitir application/problem+json en las respuestas de error automáticamente, incluida la negociación de contenido.

La puerta: un endpoint filter que mapea el estado a la respuesta

El sitio más limpio para imponer la licencia en una minimal API es un IEndpointFilter: se ejecuta tras el model binding y la autenticación pero antes del handler, tiene el HttpContext, y puede cortocircuitar devolviendo un IResult en vez de llamar a next.

public sealed class LicenseGateFilter(ILicenseState license) : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        var status = license.Current;   // already-verified snapshot, not a fresh crypto check

        // Happy paths run the handler.
        if (status is LicenseStatus.Valid or LicenseStatus.ExpiringSoon)
        {
            if (status is LicenseStatus.ExpiringSoon)
                ctx.HttpContext.Response.Headers["Keyright-License"] = "warning; reason=expiring-soon";
            return await next(ctx);
        }

        // Everything else short-circuits with a problem+json response.
        var (code, type, title, detail) = Map(status, license);
        if (status is LicenseStatus.ServerUnavailable)
            ctx.HttpContext.Response.Headers.RetryAfter = "30";

        ctx.HttpContext.Response.Headers["Keyright-License"] = $"blocked; status={status}";

        return Results.Problem(
            statusCode: code,
            type: type,
            title: title,
            detail: detail,
            extensions: new Dictionary<string, object?>
            {
                ["licenseStatus"] = status.ToString(),
                ["expiresOn"]     = license.ExpiresOn,     // null for non-expiry failures
                ["renewUrl"]      = code == 402 ? "https://delta1labs.com/account/renew" : null,
            });
    }

    private static (int, string, string, string) Map(LicenseStatus s, ILicenseState l) => s switch
    {
        LicenseStatus.Expired           => (402, "https://delta1labs.com/problems/license-expired",
                                            "License expired", $"This license expired on {l.ExpiresOn:yyyy-MM-dd}. Renew to continue."),
        LicenseStatus.Revoked           => (403, "https://delta1labs.com/problems/license-revoked",
                                            "License revoked", "This license has been revoked. Contact your administrator."),
        LicenseStatus.MachineMismatch   => (403, "https://delta1labs.com/problems/machine-mismatch",
                                            "License not valid on this machine", "This license is bound to a different machine."),
        LicenseStatus.EntitlementMissing=> (403, "https://delta1labs.com/problems/entitlement-missing",
                                            "Feature not licensed", "Your license does not include this feature."),
        LicenseStatus.SignatureInvalid  => (403, "https://delta1labs.com/problems/license-invalid",
                                            "License invalid", "The license could not be verified."),
        LicenseStatus.ServerUnavailable => (503, "https://delta1labs.com/problems/license-unavailable",
                                            "License service unavailable", "Could not reach the license service. Please retry shortly."),
        _                               => (403, "https://delta1labs.com/problems/license-invalid",
                                            "License invalid", "The license could not be verified."),
    };
}

Adjúntalo a los endpoints — o a un grupo entero — que la licencia protege:

var pro = app.MapGroup("/api/pro").AddEndpointFilter<LicenseGateFilter>();
pro.MapPost("/export", (ExportRequest r) => Export(r));

Dos decisiones deliberadas. Primera, license.Current lee una instantánea ya verificada — el filtro nunca vuelve a ejecutar una comprobación de firma por petición, así que la puerta añade una búsqueda en diccionario, no criptografía, a la ruta caliente. Segunda, la cabecera de respuesta Keyright-License lleva el estado legible por máquina incluso en la ruta feliz (como warning para una licencia que caduca), para que un servicio hermano o una CLI puedan reaccionar — renovar, avisar, aplicar backoff — leyendo una cabecera en vez de analizar el cuerpo. Lleva el estado, nunca la clave.

4xx eres tú, 5xx somos nosotros

La separación entre ServerUnavailable → 503 + Retry-After y la familia 4xx es lo que hace que un cliente HTTP genérico se comporte. Un 402 o 403 es una respuesta definitiva causada por el cliente: no cambiará al reintentar, no hay Retry-After, y un cliente bien educado lo muestra a una persona. Un 503 con Retry-After: 30 es lo contrario — ahora no, vuelve a preguntar — y una política de reintento estándar (Polly, la resiliencia de HttpClient) aplicará backoff y lo reintentará automáticamente mientras deja en paz los 4xx. La lógica de gracia que decide cuánto tiempo sigue sirviendo la app mientras el servicio de licencias está inaccesible vive detrás de la puerta; la puerta solo informa del estado que recibe.

La prueba: afirmar el estado, el tipo y la clave ausente

Una puerta así merece una prueba WebApplicationFactory que fije las tres partes del contrato — el código de estado, el type del problema y lo único que nunca debe estar en el cuerpo.

[Fact]
public async Task Expired_license_returns_402_problem_and_never_the_key()
{
    await using var app = new LicensedAppFactory(LicenseStatus.Expired, key: "KR-SECRET-KEY-DO-NOT-LEAK");
    var client = app.CreateClient();

    var res = await client.PostAsJsonAsync("/api/pro/export", new { format = "csv" });

    Assert.Equal(HttpStatusCode.PaymentRequired, res.StatusCode);          // 402
    Assert.Equal("application/problem+json", res.Content.Headers.ContentType!.MediaType);

    var body = await res.Content.ReadAsStringAsync();
    using var doc = JsonDocument.Parse(body);
    Assert.Equal("https://delta1labs.com/problems/license-expired",
        doc.RootElement.GetProperty("type").GetString());
    Assert.Equal("Expired", doc.RootElement.GetProperty("licenseStatus").GetString());

    // The bearer credential must never appear in an error the client can copy.
    Assert.DoesNotContain("KR-SECRET-KEY-DO-NOT-LEAK", body);
    Assert.Equal("blocked; status=Expired", res.Headers.GetValues("Keyright-License").Single());
}

Esa última aserción es la que vale la pena conservar para siempre. Los códigos de estado y los tipos de problema son un contrato que puedes evolucionar; una clave filtrada en un cuerpo de error es una divulgación de credenciales que se quedará en un ticket de soporte durante años. El trabajo de una puerta de licencia es decir no con claridad — a la pila HTTP, a la política de reintentos del cliente y a la persona que lee el mensaje — sin regalar nada que el lector no tuviera ya.

Si estás conectando la verificación de licencias en un servicio .NET, Keyright te entrega el LicenseStatus verificado; el contrato HTTP de arriba es cómo lo haces legible para todo lo que está aguas abajo.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.