< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.JwtTwoFactorChallengeTokenIssuer
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/JwtTwoFactorChallengeTokenIssuer.cs
Line coverage
96%
Covered lines: 30
Uncovered lines: 1
Coverable lines: 31
Total lines: 125
Line coverage: 96.7%
Branch coverage
68%
Covered branches: 11
Total branches: 16
Branch coverage: 68.7%
Method coverage
100%
Covered methods: 4
Fully covered methods: 3
Total methods: 4
Method coverage: 100%
Full method coverage: 75%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
IssueAsync(...)100%11100%
IsSignedAndLiveAsync(...)50%22100%
ValidateAsync()71.42%141490.9%

File(s)

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

#LineLine coverage
 1using ArturRios.Heimdall.Command.Services;
 2using ArturRios.Heimdall.Shared.Security;
 3using ArturRios.Jwt;
 4using ArturRios.Util.WebApi.Security.Authentication;
 5using ArturRios.Util.WebApi.Security.Interfaces;
 6
 7namespace ArturRios.Heimdall.WebApi.Security;
 8
 9/// <summary>
 10///     Issues and validates UC-38's short-lived challenge token as the application's own HMAC-signed
 11///     JWT (FR-2F-07…FR-2F-10, NFR-17), signed with the same secret as
 12///     <see cref="JwtAuthTokenIssuer" />'s full tokens but built and read independently of it, since
 13///     the two carry deliberately different claims.
 14/// </summary>
 15/// <remarks>
 16///     <para>
 17///         <b>Expiry.</b> Taken from <see cref="TwoFactorLifetimes.ChallengeToken" /> (10 minutes,
 18///         NFR-17) rather than added to <see cref="JwtConfiguration" /> as a second environment
 19///         variable: a full login token's lifetime is meant to be tuned per deployment, but a
 20///         challenge token's is a security property of the use case itself, so there is no
 21///         legitimate reason an operator would want it longer, and every reason to keep it fixed.
 22///     </para>
 23///     <para>
 24///         It is read from that shared type rather than written here because the number is not this
 25///         class's to choose. The App method would be served by a much shorter window — an
 26///         authenticator generates a code on demand, so its holder really can finish UC-38 within
 27///         moments of UC-11 returning the token. The Email method cannot: "within moments" is a
 28///         claim about someone else's mail infrastructure, and FR-2F-03 already committed to
 29///         tolerating ten minutes of it. Both methods share one challenge token, so its lifetime has
 30///         to be the longer of the two, and it has to be the <em>same</em> ten minutes the code it
 31///         was issued with gets — a shorter one silently retracted that tolerance and left a
 32///         correct, unexpired code with nowhere to be presented, since FR-2F-10 makes second-factor
 33///         verification the only endpoint that will take a challenge token.
 34///     </para>
 35///     <para>
 36///         <b>Claims.</b> Built directly rather than through <see cref="IdentityUserMapper" />'s
 37///         normal login path: the token carries the person's <c>PublicId</c>, their role (required by
 38///         <see cref="IAuthenticatedUserMapper.FromClaims" />, which cannot resolve an
 39///         <see cref="IdentityUser" /> without one), and <see cref="IdentityUser.MfaPending" /> set —
 40///         but never a <c>ScopeId</c>, <c>OwnedScopeIds</c>, or scope-permission claim, since
 41///         <see cref="IdentityUser" /> is constructed here with none of those populated. Carrying a
 42///         role at all is what lets <see cref="MfaPendingGuardFilter" /> actually authenticate and
 43///         then reject the token if it is misused as a bearer credential elsewhere (FR-2F-10) — a
 44///         token the claims mapper could not parse at all would already be rejected by
 45///         <c>AuthenticationMiddleware</c> before the filter ever ran, which would leave the filter
 46///         untested by construction rather than genuinely enforcing anything.
 47///     </para>
 48///     <para>
 49///         <b>Audience.</b> <see cref="PersonTokenAudiences.TwoFactorChallenge" />, not the full
 50///         token's. The claims above mean nothing to a consumer, which validates signature, issuer,
 51///         audience and lifetime; under the full token's audience it would take a challenge — what a
 52///         password alone obtains — for a sign-in (Threat Model TH-06).
 53///     </para>
 54/// </remarks>
 20555public class JwtTwoFactorChallengeTokenIssuer(
 20556    JwtConfiguration configuration,
 20557    PersonTokenAudiences audiences,
 20558    JwtHandler jwtHandler,
 20559    IAuthenticatedUserMapper mapper) : ITwoFactorChallengeTokenIssuer, ITwoFactorChallengeTokenValidator
 60{
 61    public Task<AuthToken> IssueAsync(Guid personId, int roleId, Guid challengeId, Guid securityStamp)
 62    {
 3263        var identity = new IdentityUser(personId, roleId)
 3264        {
 3265            MfaPending = true, ChallengeId = challengeId, SecurityStamp = securityStamp
 3266        };
 67
 68        // TH-06: under its own audience, so a consumer pinning the person audience refuses it.
 3269        var challengeConfiguration = configuration with
 3270        {
 3271            Audience = audiences.TwoFactorChallenge,
 3272            Claims = mapper.ToClaims(identity),
 3273            ExpirationInSeconds = TwoFactorLifetimes.ChallengeToken.TotalSeconds
 3274        };
 75
 3276        var token = jwtHandler.CreateToken(challengeConfiguration);
 77
 3278        return Task.FromResult(new AuthToken(token, DateTime.UtcNow.Add(TwoFactorLifetimes.ChallengeToken)));
 79    }
 80
 81    /// <summary>
 82    ///     Signature (HS256 only) and lifetime, against every configured key when there are any — the
 83    ///     same rule the bearer-token validator applies — so rotating the signing secret does not void
 84    ///     the challenges already in flight while it keeps every full token issued beside them valid.
 85    /// </summary>
 86    private Task<bool> IsSignedAndLiveAsync(string token) =>
 9787        configuration.Keys.Count > 0
 9788            ? jwtHandler.IsTokenValidAsync(token, configuration.Keys)
 9789            : jwtHandler.IsTokenValidAsync(token, configuration.Secret);
 90
 91    public async Task<TwoFactorChallengePrincipal?> ValidateAsync(string? token)
 92    {
 93        // Signature and lifetime (JwtHandler.IsTokenValidAsync validates both) — AF-38a's "expired
 94        // or invalid".
 9795        if (string.IsNullOrWhiteSpace(token) || !await IsSignedAndLiveAsync(token))
 96        {
 697            return null;
 98        }
 99
 100        // TB-5: issued by Heimdall, for the challenge audience alone. The signature proves only that
 101        // the key signed it, and the key is not Heimdall's alone.
 91102        if (!TokenOrigin.IsIssuedFor(token, configuration.Issuer, audiences.TwoFactorChallenge))
 103        {
 9104            return null;
 105        }
 106
 82107        var claims = TokenClaimsReader.Read(token);
 108
 82109        if (claims is null)
 110        {
 0111            return null;
 112        }
 113
 114        // FR-2F-10: only a token carrying the MFA-pending claim is a challenge token at all — a full
 115        // login token that happens to still be signature-valid is not accepted here either. The mapper
 116        // reads that claim only under the challenge audience, so one carried by any other token is
 117        // not accepted either (TH-06).
 118        // FR-2F-10 again: a challenge token names which challenge it is, so UC-38 can refuse one that
 119        // was already redeemed or has been superseded by a newer login. One without that claim
 120        // cannot be checked, and so is not accepted either.
 82121        return mapper.FromClaims(claims) is IdentityUser { MfaPending: true, ChallengeId: { } challengeId } identity
 82122            ? new TwoFactorChallengePrincipal(identity.Id, challengeId, identity.SecurityStamp)
 82123            : null;
 97124    }
 125}