Skip to content
← Tous les articles
· Delta1 Labs KeyrightLicences.NETAnalyse approfondie

Quel statut HTTP mérite un contrôle de licence en échec ? 402, 403, Problem Details et un en-tête que le client peut lire

Quand une API sous licence décide qu'un appelant n'a pas le droit de s'exécuter, le pire qu'elle puisse faire est de renvoyer un 500 nu avec une stack trace, et le deuxième pire est un 200 avec { "ok": false } qu'aucun client ne remarquera jamais. Ce billet établit le contrat HTTP qu'une porte de licence doit réellement à ses appelants : pourquoi un échec de licence n'est pas un 401 (l'appelant est parfaitement authentifié — c'est la licence qui a expiré), quand le 403 est juste et quand le 402 Payment Required en dit plus, comment transporter la raison lisible par machine dans un corps application/problem+json conforme à la RFC 9457 avec des membres d'extension sur lesquels un client peut brancher, et le seul en-tête de réponse — jamais la clé — qui permet à une CLI ou à un service frère de réagir sans analyser de la prose. Il construit la porte sous forme d'un IEndpointFilter ASP.NET Core qui projette le LicenseStatus de Keyright sur des codes de statut via IProblemDetailsService, ajoute Retry-After quand le service de licences est brièvement injoignable et que la fenêtre de grâce est encore ouverte, et se termine par un test WebApplicationFactory qui vérifie le statut, le type de problème et que la clé signée n'apparaît jamais dans le corps.

Un service sous licence prend une décision à l’entrée — cet appelant a-t-il le droit de s’exécuter maintenant ? — et la réponse HTTP qu’il envoie quand la réponse est non est presque toujours une réflexion après coup. Les deux échecs courants sont un 500 nu avec une stack trace (qui ne dit rien à l’appelant et expose vos entrailles) et un 200 OK portant { "ok": false } dans le corps (qu’aucun client HTTP, proxy ou politique de nouvelle tentative ne remarquera jamais, puisque pour la pile la requête a réussi). Entre les deux se trouve le contrat que la porte doit réellement à ses appelants : le bon code de statut, une raison lisible par machine, et un en-tête sur lequel un client peut brancher — sans jamais renvoyer la clé de licence en écho.

Ce billet construit ce contrat autour d’une licence Keyright. La vérification elle-même — contrôle de la signature, de l’expiration, du lien à la machine — est traitée ailleurs ; ici la licence a déjà été vérifiée et réduite à un LicenseStatus, et la seule question est comment transformer ce statut en une réponse HTTP qu’une CLI, un navigateur et un service frère sauront tous gérer correctement.

Un échec de licence n’est pas un échec d’authentification

Le réflexe est de saisir 401 Unauthorized, parce que la requête a été « rejetée ». C’est le mauvais axe. 401 signifie je ne sais pas qui vous êtes ; c’est un signal d’authentification, et la spécification exige qu’il porte un en-tête WWW-Authenticate indiquant au client comment présenter ses identifiants. Un client qui reçoit un 401 va se ré-authentifier — et si ses identifiants sont valides, il réussira, réessaiera et butera exactement sur le même mur. Vous avez construit une boucle.

Un échec de licence est orthogonal à l’identité. L’appelant peut être parfaitement authentifié — un jeton valide, une clé d’API connue — et ce qui a expiré, c’est la licence. Le code de statut doit dire « je sais exactement qui vous êtes, et ceci reste interdit », c’est-à-dire 403 Forbidden. Cela couvre une clé révoquée, un décalage de machine, ou une requête vers une fonctionnalité que la licence n’inclut pas.

Il y a un cas où le 403 sous-estime la situation. Quand la licence a simplement expiré — l’abonnement est arrivé à son terme — le remède n’est pas « demander la permission à un administrateur », c’est « renouveler ». Pour cela, 402 Payment Required en dit plus. Il a été marqué réservé dans la spécification HTTP d’origine, mais il est transmis proprement par tous les proxys et largement utilisé aujourd’hui par les API d’abonnement ; la seule règle est de ne pas attendre d’un navigateur qu’il en fasse quelque chose de magique. Émettre un 402 permet à un client de distinguer un mur commercial d’un mur de permissions, ce qui est précisément la distinction pour laquelle une porte de licence existe.

LicenseStatus → statut HTTP + problem+jsonLicenseStatusValidExpiringSoonExpiredRevokedMachineMismatchEntitlementMissingSignatureInvalidServerUnavailable200 OK → le gestionnaire s'exécuteExpiringSoon ajoute Keyright-License: warning402 Payment Required — renouveler403 Forbiddenrévoquée · machine · droit· signature invalide503 + Retry-After (grâce ouverte)réessayer plus tard ; la clé n'a rienTout corps 4xx/5xx est application/problem+json avec une URI de type stable et un membre licenseStatus — et jamais la clé.

La raison appartient à un corps problem+json

Un code de statut est un simple nombre ; un client qui veut afficher un message utile ou brancher sur la raison précise a besoin d’un détail structuré. La forme standard pour cela est la RFC 9457 — Problem Details for HTTP APIs, successeur de la RFC 7807 — servie en 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"
}

L’URI type est le contrat. Un client branche dessus — une chaîne stable que vous promettez de ne pas changer — et non sur le title humain, que vous êtes libre de reformuler ou de localiser. Les membres sous detail sont des membres d’extension : la RFC 9457 vous laisse ajouter n’importe quel JSON, et c’est là que vont les faits propres à la licence. licenseStatus reflète l’énumération de Keyright, expiresOn donne à une CLI quelque chose à afficher, renewUrl donne à une interface un bouton. Ce qui n’y est pas, c’est la clé, la signature ou l’e-mail du licencié — le corps d’erreur est l’artefact le plus copié d’un flux de support, collé dans des tickets et des captures d’écran, il obéit donc à la même règle qu’une ligne de journal.

ASP.NET Core prend cela en charge nativement. Enregistrez les services une fois :

builder.Services.AddProblemDetails();

AddProblemDetails() enregistre un IProblemDetailsService et câble le framework pour émettre automatiquement application/problem+json sur les réponses d’erreur, négociation de contenu comprise.

La porte : un filtre d’endpoint qui projette le statut sur la réponse

L’endroit le plus propre pour imposer la licence sur une minimal API est un IEndpointFilter : il s’exécute après le model binding et l’authentification mais avant le gestionnaire, il dispose du HttpContext, et il peut court-circuiter en renvoyant un IResult au lieu d’appeler 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."),
    };
}

Attachez-le aux endpoints — ou à un groupe entier — que la licence protège :

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

Deux choix délibérés. D’abord, license.Current lit un instantané déjà vérifié — le filtre ne relance jamais une vérification de signature par requête, la porte ajoute donc une consultation de dictionnaire, pas de la cryptographie, au chemin chaud. Ensuite, l’en-tête de réponse Keyright-License porte l’état lisible par machine même sur le chemin nominal (sous forme de warning pour une licence qui arrive à expiration), de sorte qu’un service frère ou une CLI peut réagir — renouveler, avertir, temporiser — en lisant un seul en-tête au lieu d’analyser le corps. Il porte le statut, jamais la clé.

4xx c’est vous, 5xx c’est nous

La séparation entre ServerUnavailable → 503 + Retry-After et la famille 4xx est ce qui fait qu’un client HTTP générique se comporte bien. Un 402 ou un 403 est une réponse définitive, imputable au client : elle ne changera pas en réessayant, il n’y a pas de Retry-After, et un client bien élevé la fait remonter à un humain. Un 503 avec Retry-After: 30 est l’inverse — pas maintenant, redemandez — et une politique de nouvelle tentative standard (Polly, la résilience de HttpClient) temporisera et réessaiera automatiquement tout en laissant les 4xx tranquilles. La logique de grâce qui décide combien de temps l’application continue de servir quand le service de licences est injoignable vit derrière la porte ; la porte ne fait que rapporter l’état qu’on lui transmet.

Le test : vérifier le statut, le type, et l’absence de la clé

Une porte comme celle-ci mérite un test WebApplicationFactory qui fige les trois volets du contrat — le code de statut, le type du problème, et la seule chose qui ne doit jamais se trouver dans le corps.

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

Cette dernière assertion est celle qu’il faut garder pour toujours. Les codes de statut et les types de problème sont un contrat que vous pouvez faire évoluer ; une clé fuitée dans un corps d’erreur est une divulgation d’identifiant qui restera dans un ticket de support pendant des années. Le travail d’une porte de licence est de dire non clairement — à la pile HTTP, à la politique de nouvelle tentative du client et à l’humain qui lit le message — sans rien livrer que le lecteur n’avait pas déjà.

Si vous câblez la vérification de licence dans un service .NET, Keyright vous remet le LicenseStatus vérifié ; le contrat HTTP ci-dessus est la manière de le rendre lisible pour tout ce qui se trouve en aval.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.