< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.ApplicationTokenOptions
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/ApplicationTokenOptions.cs
Line coverage
100%
Covered lines: 33
Uncovered lines: 0
Coverable lines: 33
Total lines: 111
Line coverage: 100%
Branch coverage
91%
Covered branches: 22
Total branches: 24
Branch coverage: 91.6%
Method coverage
100%
Covered methods: 6
Fully covered methods: 6
Total methods: 6
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Audience()100%11100%
get_RejectedExpiration()100%11100%
.cctor()100%11100%
FromEnvironment()100%11100%
Parse(...)95.45%2222100%
Comparable(...)50%22100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/ApplicationTokenOptions.cs

#LineLine coverage
 1using System.Globalization;
 2
 3namespace ArturRios.Heimdall.WebApi.Security;
 4
 5/// <summary>
 6///     The audience and lifetime of application tokens (UC-56, FR-AP-13), read once at start-up.
 7/// </summary>
 8/// <remarks>
 9///     <para>
 10///         <b>Why the audience is required and distinct.</b> Application tokens are signed with the
 11///         person tokens' key pair. A consumer that validates person tokens offline would take an
 12///         application token for a person if both carried one audience; a distinct audience is what
 13///         makes it refuse (Threat Model TH-35). So start-up refuses a missing audience, and one
 14///         equal to <c>HEIMDALL_AUTH_TOKEN_AUDIENCE</c> or to either audience derived from it for
 15///         challenge and subject-rights-only tokens (<see cref="PersonTokenAudiences" />), rather
 16///         than run without the separation. "Equal" is judged as a validator would:
 17///         Microsoft.IdentityModel ignores a trailing slash when it matches <c>aud</c>, so
 18///         <c>heimdall/</c> and <c>heimdall</c> are the same audience.
 19///     </para>
 20///     <para>
 21///         <b>Lifetime.</b> Ten minutes by default (TH-34): short enough that a leaked token is soon
 22///         worthless, while UC-57's live re-check makes a revocation effective at once regardless. An
 23///         unusable value falls back to the default rather than stopping start-up, as
 24///         <c>HEIMDALL_AUTH_TOKEN_EXPIRATION_IN_SECONDS</c> does.
 25///     </para>
 26/// </remarks>
 27/// <param name="Audience">The <c>aud</c> every application token carries and UC-57 requires.</param>
 28/// <param name="Lifetime">How long an application token is valid after issue.</param>
 282329public sealed record ApplicationTokenOptions(string Audience, TimeSpan Lifetime)
 30{
 31    /// <summary>The variable naming the application token audience. Required.</summary>
 32    public const string AudienceVariable = "HEIMDALL_APPLICATION_TOKEN_AUDIENCE";
 33
 34    /// <summary>The variable carrying the lifetime in seconds. Optional.</summary>
 35    public const string ExpirationVariable = "HEIMDALL_APPLICATION_TOKEN_EXPIRATION_IN_SECONDS";
 36
 37    /// <summary>
 38    ///     The configured lifetime text when it was present but unusable and
 39    ///     <see cref="DefaultLifetime" /> was applied instead; <c>null</c> when the variable was absent
 40    ///     or usable. Start-up warns on it.
 41    /// </summary>
 181942    public string? RejectedExpiration { get; init; }
 43
 44    private const string PersonAudienceVariable = "HEIMDALL_AUTH_TOKEN_AUDIENCE";
 45
 46    private const double MaxLifetimeInSeconds = 86400;
 47
 48    /// <summary>The lifetime used when none, or an unusable one, is configured.</summary>
 249    public static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(10);
 50
 51    /// <summary>Reads the options from the environment; throws when the audience is unusable.</summary>
 89952    public static ApplicationTokenOptions FromEnvironment() => Parse(
 89953        Environment.GetEnvironmentVariable(AudienceVariable),
 89954        Environment.GetEnvironmentVariable(ExpirationVariable),
 89955        Environment.GetEnvironmentVariable(PersonAudienceVariable));
 56
 57    /// <summary>
 58    ///     Builds the options from raw values. Throws <see cref="InvalidOperationException" /> when
 59    ///     <paramref name="audience" /> is blank, or equals <paramref name="personAudience" /> or an
 60    ///     audience derived from it (<see cref="PersonTokenAudiences" />), ignoring surrounding
 61    ///     whitespace and a trailing slash.
 62    /// </summary>
 63    public static ApplicationTokenOptions Parse(string? audience, string? expirationInSeconds, string? personAudience)
 64    {
 92365        if (string.IsNullOrWhiteSpace(audience))
 66        {
 367            throw new InvalidOperationException(
 368                $"Environment variable '{AudienceVariable}' is unset. Application tokens need an " +
 369                $"audience of their own, different from '{PersonAudienceVariable}'.");
 70        }
 71
 92072        var trimmed = audience.Trim();
 73
 92074        if (string.Equals(Comparable(trimmed), Comparable(personAudience), StringComparison.Ordinal))
 75        {
 476            throw new InvalidOperationException(
 477                $"Environment variable '{AudienceVariable}' equals '{PersonAudienceVariable}'. An " +
 478                "application token must carry an audience person tokens do not, or a consumer " +
 479                "validating person tokens would accept it as one.");
 80        }
 81
 82        // Nor may it be one of the audiences derived from the person audience, or a consumer checking
 83        // only the application audience would take a challenge or a restricted identity's token for
 84        // an application's.
 91685        var derived = new PersonTokenAudiences(personAudience ?? string.Empty);
 86
 548987        foreach (var personTokenAudience in new[] { derived.TwoFactorChallenge, derived.SubjectRights })
 88        {
 183089            if (string.Equals(Comparable(trimmed), Comparable(personTokenAudience), StringComparison.Ordinal))
 90            {
 391                throw new InvalidOperationException(
 392                    $"Environment variable '{AudienceVariable}' equals '{Comparable(personTokenAudience)}', " +
 393                    $"the audience Heimdall derives from '{PersonAudienceVariable}' for challenge or " +
 394                    "subject-rights-only tokens. An application token must carry an audience no person " +
 395                    "token does.");
 96            }
 97        }
 98
 91399        var usable = double.TryParse(
 913100                         expirationInSeconds, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) &&
 913101                     double.IsFinite(seconds) && seconds > 0 && seconds <= MaxLifetimeInSeconds;
 102
 913103        return new ApplicationTokenOptions(trimmed, usable ? TimeSpan.FromSeconds(seconds) : DefaultLifetime)
 913104        {
 913105            RejectedExpiration = usable || string.IsNullOrWhiteSpace(expirationInSeconds) ? null : expirationInSeconds
 913106        };
 107    }
 108
 109    /// <summary>An audience as a token validator compares it: trimmed, with no trailing slash.</summary>
 5503110    private static string? Comparable(string? audience) => audience?.Trim().TrimEnd('/');
 111}