< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.TokenSecurityRegistrationExtensions
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Registration/TokenSecurityRegistrationExtensions.cs
Line coverage
100%
Covered lines: 58
Uncovered lines: 0
Coverable lines: 58
Total lines: 167
Line coverage: 100%
Branch coverage
100%
Covered branches: 8
Total branches: 8
Branch coverage: 100%
Method coverage
100%
Covered methods: 5
Fully covered methods: 5
Total methods: 5
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
ReplaceLibraryTokenValidator(...)100%44100%
AddTokenSecurity(...)100%11100%
WarnOnRetiredTokenSecrets()100%44100%
SetRetiredTokenSecretVariables(...)100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Registration/TokenSecurityRegistrationExtensions.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.DependencyInjection;
 2using ArturRios.Heimdall.Command.Services;
 3using ArturRios.Heimdall.Shared.Security;
 4using ArturRios.Heimdall.WebApi.Security;
 5using ArturRios.Jwt;
 6using ArturRios.Util.WebApi.Configuration;
 7using ArturRios.Util.WebApi.Security.Authentication;
 8using ArturRios.Util.WebApi.Security.Enums;
 9using ArturRios.Util.WebApi.Security.Extensions;
 10using ArturRios.Util.WebApi.Security.Interfaces;
 11using FluentValidation;
 12using Serilog;
 13
 14namespace ArturRios.Heimdall.WebApi;
 15
 16public static class TokenSecurityRegistrationExtensions
 17{
 18    /// <summary>
 19    ///     The shared HS256 secret and the previous one, which 3.x still verified Heimdall's earlier
 20    ///     tokens with and 4.0.0 retired (Threat Model TH-43). Their values are never read: start-up
 21    ///     only warns while either is set, so the operator removes it.
 22    /// </summary>
 223    private static readonly string[] RetiredTokenSecretVariables =
 224        ["HEIMDALL_AUTH_TOKEN_SECRET", "HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS"];
 25
 26    /// <summary>
 27    ///     Puts <see cref="PersonTokenValidator" /> in the place of the <see cref="JwtTokenValidator" />
 28    ///     that <c>AddTokenAuthentication</c> registers as an <see cref="ITokenValidator" />, keeping the
 29    ///     library's validator as the inner one it wraps.
 30    /// </summary>
 31    /// <remarks>
 32    ///     TB-5: the library's validator checks the signature and lifetime but not the issuer or the
 33    ///     audience, and Heimdall's key signs every kind of token. PersonTokenValidator wraps it and
 34    ///     takes its place, so a token reaches the middleware's user only if Heimdall issued it, for
 35    ///     one of the person audiences. When the library registers its validator in some other way,
 36    ///     start-up stops and says why, rather than running with the library's validator alone.
 37    /// </remarks>
 38    /// <exception cref="InvalidOperationException">
 39    ///     The services hold no <see cref="ITokenValidator" /> registered as <see cref="JwtTokenValidator" />.
 40    /// </exception>
 41    public static void ReplaceLibraryTokenValidator(IServiceCollection services)
 42    {
 96843        var libraryValidator = services.SingleOrDefault(descriptor =>
 51102344            descriptor.ServiceType == typeof(ITokenValidator) &&
 51102345            descriptor.ImplementationType == typeof(JwtTokenValidator));
 46
 96847        if (libraryValidator is null)
 48        {
 149            throw new InvalidOperationException(
 150                "ArturRios.Util.WebApi no longer registers JwtTokenValidator as an ITokenValidator; " +
 151                "PersonTokenValidator cannot replace it. Refusing to start.");
 52        }
 53
 96754        services.Remove(libraryValidator);
 96755        services.AddSingleton<JwtTokenValidator>();
 96756        services.AddSingleton<ITokenValidator, PersonTokenValidator>();
 96757    }
 58
 59    /// <summary>
 60    ///     Registers token signing, the person and application token services and the token
 61    ///     authentication, then the rate limiting and the password hash gate that sit beside them.
 62    /// </summary>
 63    public static IServiceCollection AddTokenSecurity(this IServiceCollection services)
 64    {
 65        // Authentication is Util.WebApi's alone: ASP.NET Core's authentication and authorization
 66        // middlewares are not registered, because nothing here reads HttpContext.User or carries
 67        // ASP.NET Core's [Authorize] — the controllers use the library's attributes, which read the
 68        // user AuthenticationMiddleware attaches.
 69        //
 70        // AuthenticationMiddleware resolves AuthenticationOptions and the token validators from the
 71        // container, and JwtTokenValidator additionally needs JwtConfiguration, JwtHandler, and the
 72        // claims mapper. IdentityUserMapper replaces the library default so tokens carry PublicIds
 73        // and the scope claims of FR-AU-04. Defaults are kept otherwise: app JWT only, read from the
 74        // Authorization header, user rebuilt from claims — so no IAuthenticationProvider is required
 75        // and no database read happens per request.
 76        //
 77        // Every token is signed ES256 with the current key; a missing or unusable key stops start-up
 78        // here, naming the variable. HS256 is refused: the shared secret is retired (TH-43), and its
 79        // variables are only looked at to tell the operator to remove them.
 97380        var signingKeys = TokenSigningKeys.FromEnvironment();
 81
 96682        var jwtConfiguration = JwtConfigurationBuilder.BuildJwtConfiguration(signingKeys);
 96683        services.AddSingleton(signingKeys);
 96684        services.AddSingleton(jwtConfiguration);
 96685        services.AddSingleton<JwtHandler>();
 86
 87        // Thumbprints only, which are public: they are what the published key set and every token's
 88        // kid carry, so a consumer can be checked against them. No key material is logged.
 96689        Log.Information(
 96690            "Tokens: signed ES256 with key {KeyId}; published keys {KeyIds}; HS256 tokens {LegacyState}",
 96691            signingKeys.Current.Id,
 193292            signingKeys.ConfigurationKeys.Select(key => key.Id).ToArray(),
 96693            "refused");
 94
 96695        WarnOnRetiredTokenSecrets();
 96
 97        // TH-06/TH-35: the challenge and subject-rights-only tokens carry audiences derived from the
 98        // person audience, so a consumer pinning it refuses them. Logged, like the application
 99        // audience below, so a consumer can be checked against them.
 966100        var personTokenAudiences = PersonTokenAudiences.From(jwtConfiguration);
 966101        services.AddSingleton(personTokenAudiences);
 966102        Log.Information(
 966103            "Person tokens: audience {Audience}; challenge tokens {ChallengeAudience}; " +
 966104            "subject-rights-only tokens {SubjectRightsAudience}",
 966105            personTokenAudiences.Person, personTokenAudiences.TwoFactorChallenge,
 966106            personTokenAudiences.SubjectRights);
 107
 108        // UC-56/UC-57: application tokens share the signing keys but not the audience. Read here,
 109        // with the rest of the token configuration, so a missing or equal audience stops start-up
 110        // before anything is served (TH-35).
 966111        var applicationTokens = ApplicationTokenOptions.FromEnvironment();
 966112        services.AddSingleton(applicationTokens);
 966113        ConfigurationWarnings.WarnOnFallback(ApplicationTokenOptions.ExpirationVariable, applicationTokens.RejectedExpir
 966114            ApplicationTokenOptions.DefaultLifetime.TotalSeconds);
 966115        services.AddScoped<JwtApplicationTokenService>();
 966116        services.AddScoped<IApplicationTokenIssuer>(provider =>
 988117            provider.GetRequiredService<JwtApplicationTokenService>());
 966118        services.AddScoped<IApplicationTokenValidator>(provider =>
 978119            provider.GetRequiredService<JwtApplicationTokenService>());
 120
 121        // The audience is not a secret; logging it makes a mismatch with a consumer diagnosable.
 966122        Log.Information(
 966123            "Application tokens: audience {Audience}, lifetime {Seconds}s",
 966124            applicationTokens.Audience, applicationTokens.Lifetime.TotalSeconds);
 125
 966126        services.AddTokenAuthentication<IdentityUserMapper>(options =>
 966127        {
 966128            options.Source = TokenSource.Header;
 966129            options.EnableJwt = true;
 966130            options.EnableGoogle = false;
 966131            options.JwtMode = JwtValidationMode.ClaimsOnly;
 1932132        });
 133
 966134        ReplaceLibraryTokenValidator(services);
 135
 966136        services.AddAuthEndpointRateLimiting();
 137
 138        // TH-03: bound how many Argon2id derivations run at once. Read here, with the rest of the
 139        // security configuration, because it belongs to the same defence: the rate limiter caps what
 140        // one address can ask for, and this caps what the process will attempt regardless of how
 141        // many addresses ask.
 966142        PasswordHashGate.ConfigureSharedFromEnvironment();
 143
 966144        return services;
 145    }
 146
 147    /// <summary>
 148    ///     One Warning for each retired token secret variable that is still set, naming it and never its
 149    ///     value, so the operator removes it. Nothing else reads them: a value 3.x refused to start
 150    ///     with no longer stops start-up either.
 151    /// </summary>
 152    private static void WarnOnRetiredTokenSecrets()
 153    {
 5788154        foreach (var variable in SetRetiredTokenSecretVariables(Environment.GetEnvironmentVariable))
 155        {
 1928156            Log.Warning("{Variable} is set but no longer read; remove it", variable);
 157        }
 966158    }
 159
 160    /// <summary>
 161    ///     The retired token secret variables <paramref name="read" /> finds set. A value that is empty
 162    ///     or whitespace only is unset, as 3.x treated it: an env file's <c>VARIABLE=</c> reaches the
 163    ///     process as the empty string.
 164    /// </summary>
 165    public static IReadOnlyList<string> SetRetiredTokenSecretVariables(Func<string, string?> read) =>
 2913166        [.. RetiredTokenSecretVariables.Where(variable => !string.IsNullOrWhiteSpace(read(variable)))];
 167}