< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.JwtAuthTokenIssuer
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/JwtAuthTokenIssuer.cs
Line coverage
100%
Covered lines: 42
Uncovered lines: 0
Coverable lines: 42
Total lines: 104
Line coverage: 100%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage
100%
Covered methods: 4
Fully covered methods: 4
Total methods: 4
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
IssueAsync()100%11100%
IssueSubjectRightsOnlyAsync(...)100%11100%
LoadScopePermissionClaimsAsync()100%44100%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Command.Services;
 3using ArturRios.Heimdall.Domain.Entities;
 4using ArturRios.Jwt;
 5using ArturRios.Util.WebApi.Security.Interfaces;
 6using Microsoft.EntityFrameworkCore;
 7
 8namespace ArturRios.Heimdall.WebApi.Security;
 9
 10/// <summary>
 11///     Issues UC-11's authentication token as the application's own HMAC-signed JWT (FR-AU-03),
 12///     using the same <see cref="IAuthenticatedUserMapper" /> the middleware validates incoming
 13///     tokens with — so a token this class issues is one the API can always read back.
 14/// </summary>
 15/// <remarks>
 16///     Expiry comes from the registered <see cref="JwtConfiguration" />, which reads it from the
 17///     environment (NFR-03), and is reported to the caller so a client need not decode the token to
 18///     know when to re-authenticate. At issuance the acting scope's permissions whose
 19///     <see cref="ScopePermission.IncludeAsJwtClaim" /> flag is set are read from the database
 20///     (UC-31…UC-35, FR-SP) and folded into the token, so a downstream caller can authorize on them.
 21///     A User claims the permissions of the scope they belong to; a Scope Admin the union over the
 22///     scopes they own; a System Admin none, since they belong to no scope. Logically deleted scopes
 23///     and permissions are excluded, as a deleted resource is never claimed. A restricted identity
 24///     is issued a subject-rights-only token instead (NFR-24), which claims none of this, under
 25///     <see cref="PersonTokenAudiences.SubjectRights" /> rather than the full token's audience, so a
 26///     consumer pinning the person audience refuses it (Threat Model TH-35).
 27/// </remarks>
 20628public class JwtAuthTokenIssuer(
 20629    JwtConfiguration configuration,
 20630    PersonTokenAudiences audiences,
 20631    JwtHandler jwtHandler,
 20632    IAuthenticatedUserMapper mapper,
 20633    IAsyncReadOnlyRepository<ScopePermission, long> permissionReader) : IAuthTokenIssuer
 34{
 35    public async Task<AuthToken> IssueAsync(AuthTokenSubject subject)
 36    {
 7737        var permissionClaims = await LoadScopePermissionClaimsAsync(subject);
 38
 39        // The issued token carries this login's claims; everything else — secret, issuer, audience,
 40        // lifetime — comes from the configuration the validator also uses.
 7741        var user = new IdentityUser(
 7742            subject.PersonId, subject.RoleId, subject.ScopeId, subject.OwnedScopeIds)
 7743        {
 7744            DisplayName = subject.DisplayName,
 7745            ScopePermissionClaims = permissionClaims,
 7746            SecurityStamp = subject.SecurityStamp
 7747        };
 48
 7749        var tokenConfiguration = configuration with { Claims = mapper.ToClaims(user) };
 50
 7751        return new AuthToken(
 7752            jwtHandler.CreateToken(tokenConfiguration),
 7753            DateTime.UtcNow.AddSeconds(configuration.ExpirationInSeconds));
 7754    }
 55
 56    public Task<AuthToken> IssueSubjectRightsOnlyAsync(Guid subjectId, int roleId, Guid? securityStamp)
 57    {
 58        // Built from the subject and role alone: the token admits nothing a scope, an ownership or
 59        // a permission would matter to, so it claims none — and no display name, since it is not
 60        // a token any downstream service should provision a profile from. The stamp it does carry,
 61        // so a credential change revokes it like any other person token (FR-AU-11).
 1262        var user = new IdentityUser(subjectId, roleId) { SubjectRightsOnly = true, SecurityStamp = securityStamp };
 63
 1264        var tokenConfiguration = configuration with
 1265        {
 1266            Audience = audiences.SubjectRights,
 1267            Claims = mapper.ToClaims(user)
 1268        };
 69
 1270        return Task.FromResult(new AuthToken(
 1271            jwtHandler.CreateToken(tokenConfiguration),
 1272            DateTime.UtcNow.AddSeconds(configuration.ExpirationInSeconds)));
 73    }
 74
 75    /// <summary>
 76    ///     The distinct names of the non-deleted permissions flagged <see cref="ScopePermission.IncludeAsJwtClaim" />
 77    ///     across the scopes this subject acts within — <see cref="AuthTokenSubject.ScopeId" /> for a
 78    ///     User and <see cref="AuthTokenSubject.OwnedScopeIds" /> for a Scope Admin. Empty for a
 79    ///     System Admin, who carries no scope.
 80    /// </summary>
 81    private async Task<IReadOnlyCollection<string>> LoadScopePermissionClaimsAsync(AuthTokenSubject subject)
 82    {
 7783        var scopePublicIds = subject.OwnedScopeIds.ToList();
 84
 7785        if (subject.ScopeId is not null)
 86        {
 2187            scopePublicIds.Add(subject.ScopeId.Value);
 88        }
 89
 7790        if (scopePublicIds.Count == 0)
 91        {
 5392            return [];
 93        }
 94
 2495        return await permissionReader.Query()
 2496            .Where(permission => scopePublicIds.Contains(permission.Scope.PublicId)
 2497                                 && !permission.Scope.IsDeleted
 2498                                 && !permission.IsDeleted
 2499                                 && permission.IncludeAsJwtClaim)
 24100            .Select(permission => permission.Name)
 24101            .Distinct()
 24102            .ToListAsync();
 77103    }
 104}