| | | 1 | | using System.Globalization; |
| | | 2 | | |
| | | 3 | | namespace 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> |
| | 2823 | 29 | | public 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> |
| | 1819 | 42 | | 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> |
| | 2 | 49 | | public static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(10); |
| | | 50 | | |
| | | 51 | | /// <summary>Reads the options from the environment; throws when the audience is unusable.</summary> |
| | 899 | 52 | | public static ApplicationTokenOptions FromEnvironment() => Parse( |
| | 899 | 53 | | Environment.GetEnvironmentVariable(AudienceVariable), |
| | 899 | 54 | | Environment.GetEnvironmentVariable(ExpirationVariable), |
| | 899 | 55 | | 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 | | { |
| | 923 | 65 | | if (string.IsNullOrWhiteSpace(audience)) |
| | | 66 | | { |
| | 3 | 67 | | throw new InvalidOperationException( |
| | 3 | 68 | | $"Environment variable '{AudienceVariable}' is unset. Application tokens need an " + |
| | 3 | 69 | | $"audience of their own, different from '{PersonAudienceVariable}'."); |
| | | 70 | | } |
| | | 71 | | |
| | 920 | 72 | | var trimmed = audience.Trim(); |
| | | 73 | | |
| | 920 | 74 | | if (string.Equals(Comparable(trimmed), Comparable(personAudience), StringComparison.Ordinal)) |
| | | 75 | | { |
| | 4 | 76 | | throw new InvalidOperationException( |
| | 4 | 77 | | $"Environment variable '{AudienceVariable}' equals '{PersonAudienceVariable}'. An " + |
| | 4 | 78 | | "application token must carry an audience person tokens do not, or a consumer " + |
| | 4 | 79 | | "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. |
| | 916 | 85 | | var derived = new PersonTokenAudiences(personAudience ?? string.Empty); |
| | | 86 | | |
| | 5489 | 87 | | foreach (var personTokenAudience in new[] { derived.TwoFactorChallenge, derived.SubjectRights }) |
| | | 88 | | { |
| | 1830 | 89 | | if (string.Equals(Comparable(trimmed), Comparable(personTokenAudience), StringComparison.Ordinal)) |
| | | 90 | | { |
| | 3 | 91 | | throw new InvalidOperationException( |
| | 3 | 92 | | $"Environment variable '{AudienceVariable}' equals '{Comparable(personTokenAudience)}', " + |
| | 3 | 93 | | $"the audience Heimdall derives from '{PersonAudienceVariable}' for challenge or " + |
| | 3 | 94 | | "subject-rights-only tokens. An application token must carry an audience no person " + |
| | 3 | 95 | | "token does."); |
| | | 96 | | } |
| | | 97 | | } |
| | | 98 | | |
| | 913 | 99 | | var usable = double.TryParse( |
| | 913 | 100 | | expirationInSeconds, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && |
| | 913 | 101 | | double.IsFinite(seconds) && seconds > 0 && seconds <= MaxLifetimeInSeconds; |
| | | 102 | | |
| | 913 | 103 | | return new ApplicationTokenOptions(trimmed, usable ? TimeSpan.FromSeconds(seconds) : DefaultLifetime) |
| | 913 | 104 | | { |
| | 913 | 105 | | RejectedExpiration = usable || string.IsNullOrWhiteSpace(expirationInSeconds) ? null : expirationInSeconds |
| | 913 | 106 | | }; |
| | | 107 | | } |
| | | 108 | | |
| | | 109 | | /// <summary>An audience as a token validator compares it: trimmed, with no trailing slash.</summary> |
| | 5503 | 110 | | private static string? Comparable(string? audience) => audience?.Trim().TrimEnd('/'); |
| | | 111 | | } |