< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.JwtTwoFactorChallengeTokenIssuer
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Security/JwtTwoFactorChallengeTokenIssuer.cs
Line coverage
96%
Covered lines: 27
Uncovered lines: 1
Coverable lines: 28
Total lines: 120
Line coverage: 96.4%
Branch coverage
71%
Covered branches: 10
Total branches: 14
Branch coverage: 71.4%
Method coverage
100%
Covered methods: 3
Fully covered methods: 2
Total methods: 3
Method coverage: 100%
Full method coverage: 66.6%

Metrics

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

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/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 ES256-signed
 11///     JWT (FR-2F-07…FR-2F-10, NFR-17), signed with the same key 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>Signature.</b> ES256 under any configured key, the current or the previous one, so a
 18///         rotation does not void the challenges in flight. The configured keys are EC keys alone, so
 19///         an HS256 challenge is refused (Threat Model TH-43).
 20///     </para>
 21///     <para>
 22///         <b>Expiry.</b> Taken from <see cref="TwoFactorLifetimes.ChallengeToken" /> (10 minutes,
 23///         NFR-17) rather than added to <see cref="JwtConfiguration" /> as a second environment
 24///         variable: a full login token's lifetime is meant to be tuned per deployment, but a
 25///         challenge token's is a security property of the use case itself, so there is no
 26///         legitimate reason an operator would want it longer, and every reason to keep it fixed.
 27///     </para>
 28///     <para>
 29///         It is read from that shared type rather than written here because the number is not this
 30///         class's to choose. The App method would be served by a much shorter window — an
 31///         authenticator generates a code on demand, so its holder really can finish UC-38 within
 32///         moments of UC-11 returning the token. The Email method cannot: "within moments" is a
 33///         claim about someone else's mail infrastructure, and FR-2F-03 already committed to
 34///         tolerating ten minutes of it. Both methods share one challenge token, so its lifetime has
 35///         to be the longer of the two, and it has to be the <em>same</em> ten minutes the code it
 36///         was issued with gets — a shorter one silently retracted that tolerance and left a
 37///         correct, unexpired code with nowhere to be presented, since FR-2F-10 makes second-factor
 38///         verification the only endpoint that will take a challenge token.
 39///     </para>
 40///     <para>
 41///         <b>Claims.</b> Built directly rather than through <see cref="IdentityUserMapper" />'s
 42///         normal login path: the token carries the person's <c>PublicId</c>, their role (required by
 43///         <see cref="IAuthenticatedUserMapper.FromClaims" />, which cannot resolve an
 44///         <see cref="IdentityUser" /> without one), and <see cref="IdentityUser.MfaPending" /> set —
 45///         but never a <c>ScopeId</c>, <c>OwnedScopeIds</c>, or scope-permission claim, since
 46///         <see cref="IdentityUser" /> is constructed here with none of those populated. Carrying a
 47///         role at all is what lets <see cref="MfaPendingGuardFilter" /> actually authenticate and
 48///         then reject the token if it is misused as a bearer credential elsewhere (FR-2F-10) — a
 49///         token the claims mapper could not parse at all would already be rejected by
 50///         <c>AuthenticationMiddleware</c> before the filter ever ran, which would leave the filter
 51///         untested by construction rather than genuinely enforcing anything.
 52///     </para>
 53///     <para>
 54///         <b>Audience.</b> <see cref="PersonTokenAudiences.TwoFactorChallenge" />, not the full
 55///         token's. The claims above mean nothing to a consumer, which validates signature, issuer,
 56///         audience and lifetime; under the full token's audience it would take a challenge — what a
 57///         password alone obtains — for a sign-in (Threat Model TH-06).
 58///     </para>
 59/// </remarks>
 29460public class JwtTwoFactorChallengeTokenIssuer(
 29461    JwtConfiguration configuration,
 29462    PersonTokenAudiences audiences,
 29463    JwtHandler jwtHandler,
 29464    IAuthenticatedUserMapper mapper) : ITwoFactorChallengeTokenIssuer, ITwoFactorChallengeTokenValidator
 65{
 66    public Task<AuthToken> IssueAsync(Guid personId, int roleId, Guid challengeId, Guid securityStamp)
 67    {
 3368        var identity = new IdentityUser(personId, roleId)
 3369        {
 3370            MfaPending = true, ChallengeId = challengeId, SecurityStamp = securityStamp
 3371        };
 72
 73        // TH-06: under its own audience, so a consumer pinning the person audience refuses it.
 3374        var challengeConfiguration = configuration with
 3375        {
 3376            Audience = audiences.TwoFactorChallenge,
 3377            Claims = mapper.ToClaims(identity),
 3378            ExpirationInSeconds = TwoFactorLifetimes.ChallengeToken.TotalSeconds
 3379        };
 80
 3381        var token = jwtHandler.CreateToken(challengeConfiguration);
 82
 3383        return Task.FromResult(new AuthToken(token, DateTime.UtcNow.Add(TwoFactorLifetimes.ChallengeToken)));
 84    }
 85
 86    public async Task<TwoFactorChallengePrincipal?> ValidateAsync(string? token)
 87    {
 88        // Signature and lifetime (JwtHandler.IsTokenValidAsync validates both) — AF-38a's "expired
 89        // or invalid". ES256 under the configured keys, which are EC keys alone.
 11190        if (string.IsNullOrWhiteSpace(token) || !await jwtHandler.IsTokenValidAsync(token, configuration.Keys))
 91        {
 1892            return null;
 93        }
 94
 95        // TB-5: issued by Heimdall, for the challenge audience alone. The signature proves only that
 96        // Heimdall's key signed it, and that key signs every kind of token.
 9397        if (!TokenOrigin.IsIssuedFor(token, configuration.Issuer, audiences.TwoFactorChallenge))
 98        {
 999            return null;
 100        }
 101
 84102        var claims = TokenClaimsReader.Read(token);
 103
 84104        if (claims is null)
 105        {
 0106            return null;
 107        }
 108
 109        // FR-2F-10: only a token carrying the MFA-pending claim is a challenge token at all — a full
 110        // login token that happens to still be signature-valid is not accepted here either. The mapper
 111        // reads that claim only under the challenge audience, so one carried by any other token is
 112        // not accepted either (TH-06).
 113        // FR-2F-10 again: a challenge token names which challenge it is, so UC-38 can refuse one that
 114        // was already redeemed or has been superseded by a newer login. One without that claim
 115        // cannot be checked, and so is not accepted either.
 84116        return mapper.FromClaims(claims) is IdentityUser { MfaPending: true, ChallengeId: { } challengeId } identity
 84117            ? new TwoFactorChallengePrincipal(identity.Id, challengeId, identity.SecurityStamp)
 84118            : null;
 111119    }
 120}