< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Handlers.IssueApplicationTokenCommandHandler
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/IssueApplicationTokenCommandHandler.cs
Line coverage
100%
Covered lines: 36
Uncovered lines: 0
Coverable lines: 36
Total lines: 99
Line coverage: 100%
Branch coverage
100%
Covered branches: 14
Total branches: 14
Branch coverage: 100%
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
HandleAsync()100%1414100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Handlers/IssueApplicationTokenCommandHandler.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Input;
 3using ArturRios.Heimdall.Command.Output;
 4using ArturRios.Heimdall.Command.Services;
 5using ArturRios.Heimdall.Domain.Entities;
 6using ArturRios.Heimdall.Domain.Persistence;
 7using ArturRios.Heimdall.Shared.Messages;
 8using ArturRios.Mediator.Command.Interfaces;
 9using ArturRios.Output;
 10using FluentValidation;
 11using Microsoft.EntityFrameworkCore;
 12
 13namespace ArturRios.Heimdall.Command.Handlers;
 14
 15/// <summary>
 16///     Handles <see cref="IssueApplicationTokenCommand" /> (UC-56, FR-AP-13/14): finds the live
 17///     credential by the hash of the presented secret, checks it belongs to the named application in
 18///     the named scope, both live, and issues a token.
 19/// </summary>
 20/// <remarks>
 21///     <para>
 22///         <b>One refusal.</b> An unknown secret, a revoked or expired one, another application's, a
 23///         deleted application or scope, and the wrong scope all answer
 24///         <see cref="ApplicationTokenMessages.CredentialsNotAccepted" />, so the endpoint says
 25///         nothing about which applications, scopes or credentials exist (the stance UC-11 takes for
 26///         persons). The audit entry of a refusal therefore has no target.
 27///     </para>
 28///     <para>
 29///         <b>Whitespace.</b> A secret has none, so it is trimmed: one read from a file arrives with
 30///         its trailing newline, and refusing it would be a failure nobody could diagnose from one
 31///         uniform 401.
 32///     </para>
 33///     <para>
 34///         <b>Stamped before signing.</b> <c>LastUsedAt</c> is written as one conditional column
 35///         (<see cref="IAtomicWrites.TryStampCredentialUseAsync" />) that only lands while the
 36///         credential is still live, and the token is signed only after it has. A revocation (UC-55)
 37///         that commits between the read and the stamp therefore refuses this exchange, rather than
 38///         being written back over by a whole-row save of the credential as it was read.
 39///     </para>
 40/// </remarks>
 3341public class IssueApplicationTokenCommandHandler(
 3342    IValidator<IssueApplicationTokenCommand> validator,
 3343    IAsyncReadOnlyRepository<ApplicationCredential, long> credentialReader,
 3344    IAtomicWrites atomicWrites,
 3345    IApplicationTokenIssuer issuer)
 46    : ICommandHandlerAsync<IssueApplicationTokenCommand, IssueApplicationTokenCommandOutput>
 47{
 48    public async Task<DataOutput<IssueApplicationTokenCommandOutput?>> HandleAsync(
 49        IssueApplicationTokenCommand command, CancellationToken cancellationToken = default)
 50    {
 3351        var output = DataOutput<IssueApplicationTokenCommandOutput?>.New;
 52
 53        // AF-56a.
 3354        var validation = await validator.ValidateAsync(command, cancellationToken);
 55
 3356        if (!validation.IsValid)
 57        {
 458            return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage));
 59        }
 60
 3161        var now = DateTime.UtcNow;
 3162        var hash = ApplicationCredentialSecret.HashOf(command.Secret.Trim());
 63
 3164        var credential = await credentialReader.Query()
 3165            .Include(x => x.Application)
 3166            .ThenInclude(application => application.Scope)
 3167            .Where(ApplicationCredential.LiveAt(now))
 3168            .FirstOrDefaultAsync(x => x.SecretHash == hash, cancellationToken);
 69
 70        // AF-56b — every check, one answer.
 3171        if (credential is null ||
 3172            credential.Application.PublicId != command.ApplicationId ||
 3173            credential.Application.IsDeleted ||
 3174            credential.Application.Scope.PublicId != command.ScopeId ||
 3175            credential.Application.Scope.IsDeleted)
 76        {
 1677            return output.WithError(ApplicationTokenMessages.CredentialsNotAccepted);
 78        }
 79
 80        // AF-56b again: revoked or expired since the read above.
 1581        if (!await atomicWrites.TryStampCredentialUseAsync(credential, now))
 82        {
 283            return output.WithError(ApplicationTokenMessages.CredentialsNotAccepted);
 84        }
 85
 1386        var application = credential.Application;
 1387        var token = await issuer.IssueAsync(new ApplicationTokenSubject(
 1388            application.PublicId, application.Scope.PublicId, credential.PublicId, application.Name));
 89
 1390        return output
 1391            .WithData(new IssueApplicationTokenCommandOutput
 1392            {
 1393                Id = application.PublicId,
 1394                Token = token.Token,
 1395                ExpiresAt = token.ExpiresAt
 1396            })
 1397            .WithMessage(ApplicationTokenMessages.TokenIssued);
 3398    }
 99}