< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.IdentityUserMapper
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/IdentityUserMapper.cs
Line coverage
98%
Covered lines: 77
Uncovered lines: 1
Coverable lines: 78
Total lines: 251
Line coverage: 98.7%
Branch coverage
94%
Covered branches: 53
Total branches: 56
Branch coverage: 94.6%
Method coverage
100%
Covered methods: 7
Fully covered methods: 6
Total methods: 7
Method coverage: 100%
Full method coverage: 85.7%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ToClaims(...)94.44%181896.15%
FromClaims(...)95.45%2222100%
AudienceAgrees(...)100%22100%
ReadOwnedScopeIds(...)100%66100%
ReadScopePermissionClaims(...)83.33%66100%
TryReadGuid(...)100%22100%

File(s)

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

#LineLine coverage
 1using ArturRios.Heimdall.Domain.Enums;
 2using ArturRios.Heimdall.Shared.Security;
 3using ArturRios.Util.WebApi.Security.Constants;
 4using ArturRios.Util.WebApi.Security.Interfaces;
 5using Microsoft.IdentityModel.JsonWebTokens;
 6using System.Text.Json;
 7
 8namespace ArturRios.Heimdall.WebApi.Security;
 9
 10/// <summary>
 11///     Translates between <see cref="IdentityUser" /> and the claims this API's tokens carry
 12///     (UC-11, FR-AU-04). One class owns both directions, so the claims written when a token is
 13///     issued and the claims read when it is validated cannot drift apart.
 14/// </summary>
 15/// <remarks>
 16///     Every claim value is a <c>PublicId</c> — an internal <c>bigint</c> Id is never written to a
 17///     token (NFR-15). Reading is total: any claim that cannot be interpreted yields <c>null</c>
 18///     rather than an exception, so a malformed token is rejected as unauthenticated instead of
 19///     failing the request with a 500. A token carrying <c>tokenKind</c> is an application token
 20///     (UC-56) and yields <c>null</c>: it is never a person. So does a token whose audience and marks
 21///     disagree (<see cref="PersonTokenAudiences" />): a challenge or subject-rights-only token is
 22///     read only under its own audience, and a token under either of those audiences only as that kind.
 23/// </remarks>
 24/// <param name="audiences">The person token audiences, against which each token's marks are held.</param>
 162125public class IdentityUserMapper(PersonTokenAudiences audiences) : IAuthenticatedUserMapper
 26{
 27    /// <summary>The claim carrying the caller's display name for downstream profile provisioning.</summary>
 28    public const string DisplayNameClaim = "name";
 29
 30    /// <summary>The claim holding the scope a <c>User</c> belongs to.</summary>
 31    public const string ScopeIdClaim = "scopeId";
 32
 33    /// <summary>The claim holding the scopes a <c>ScopeAdmin</c> owns, comma-separated.</summary>
 34    public const string OwnedScopeIdsClaim = "ownedScopeIds";
 35
 36    /// <summary>The claim holding the flagged scope-permission names, as a JSON array.</summary>
 37    public const string ScopePermissionClaimsClaim = "scopePermissions";
 38
 39    /// <summary>
 40    ///     The claim marking a token as a UC-38 challenge token rather than a full authentication
 41    ///     token (FR-2F-07, NFR-17). Present and <c>"true"</c> only on a challenge token; omitted on
 42    ///     every other token, the same way the scope claims are omitted when they do not apply.
 43    /// </summary>
 44    public const string MfaPendingClaim = "mfaPending";
 45
 46    /// <summary>
 47    ///     The claim naming which challenge a UC-38 challenge token stands for (FR-2F-10). Present
 48    ///     only on a challenge token, beside <see cref="MfaPendingClaim" />.
 49    /// </summary>
 50    public const string ChallengeIdClaim = "challengeId";
 51
 52    /// <summary>
 53    ///     The claim marking a token as subject-rights-only (NFR-24): issued to a restricted identity,
 54    ///     and admitted only where the subject exercises their own rights. Present and <c>"true"</c>
 55    ///     only on such a token.
 56    /// </summary>
 57    public const string SubjectRightsOnlyClaim = "subjectRightsOnly";
 58
 59    /// <summary>
 60    ///     The claim carrying the person's security stamp at issuance (FR-AU-11). Present on every
 61    ///     person token; omitted on a Google User's, which has no stamp.
 62    /// </summary>
 63    public const string SecurityStampClaim = "sst";
 64
 65    private const char OwnedScopeIdsSeparator = ',';
 66
 67    public Dictionary<string, string> ToClaims(IAuthenticatedUser user)
 68    {
 88569        var claims = new Dictionary<string, string>
 88570        {
 88571            [TokenClaimKeys.Id] = user.Id.ToString(),
 88572            [TokenClaimKeys.RoleId] = user.RoleId.ToString()
 88573        };
 74
 88575        if (user is not IdentityUser identityUser)
 76        {
 077            return claims;
 78        }
 79
 88580        if (!string.IsNullOrWhiteSpace(identityUser.DisplayName))
 81        {
 7982            claims[DisplayNameClaim] = identityUser.DisplayName;
 83        }
 84
 85        // FR-AU-04: a User carries their scope, a ScopeAdmin the scopes they own, a SystemAdmin
 86        // neither. The claims are omitted rather than emitted empty, so a token never suggests a
 87        // scope association the person does not have.
 88588        if (identityUser.ScopeId is not null)
 89        {
 4390            claims[ScopeIdClaim] = identityUser.ScopeId.Value.ToString();
 91        }
 92
 88593        if (identityUser.OwnedScopeIds.Count > 0)
 94        {
 2595            claims[OwnedScopeIdsClaim] = string.Join(
 2596                OwnedScopeIdsSeparator, identityUser.OwnedScopeIds);
 97        }
 98
 99        // FR-SP (UC-31…UC-35): the flagged scope-permission names travel as a JSON array rather than
 100        // the comma-separated form above, because — unlike PublicIds — a permission name is a freeform
 101        // string and may contain the separator. Omitted when empty, like the scope claims.
 885102        if (identityUser.ScopePermissionClaims.Count > 0)
 103        {
 3104            claims[ScopePermissionClaimsClaim] =
 3105                JsonSerializer.Serialize(identityUser.ScopePermissionClaims);
 106        }
 107
 108        // FR-2F-07/NFR-17: only a UC-38 challenge token carries this claim, and only when true —
 109        // omitted rather than emitted "false", like every other optional claim here.
 885110        if (identityUser.MfaPending)
 111        {
 88112            claims[MfaPendingClaim] = "true";
 113        }
 114
 885115        if (identityUser.ChallengeId is not null)
 116        {
 88117            claims[ChallengeIdClaim] = identityUser.ChallengeId.Value.ToString();
 118        }
 119
 120        // NFR-24: likewise present only when true.
 885121        if (identityUser.SubjectRightsOnly)
 122        {
 25123            claims[SubjectRightsOnlyClaim] = "true";
 124        }
 125
 126        // FR-AU-11: omitted rather than emitted empty when there is no stamp, like the scope claims.
 885127        if (identityUser.SecurityStamp is not null)
 128        {
 791129            claims[SecurityStampClaim] = identityUser.SecurityStamp.Value.ToString();
 130        }
 131
 885132        return claims;
 133    }
 134
 135    public IAuthenticatedUser? FromClaims(IReadOnlyDictionary<string, string> claims)
 136    {
 137        // TH-35: an application token is never a person, whatever else it carries. PersonTokenValidator
 138        // also refuses the application audience; this check keeps one off every endpoint but UC-57's,
 139        // which validates application tokens itself, whatever audience it names.
 1083140        if (claims.ContainsKey(ApplicationTokenClaims.TokenKind))
 141        {
 2142            return null;
 143        }
 144
 1081145        if (!TryReadGuid(claims, TokenClaimKeys.Id, out var id))
 146        {
 3147            return null;
 148        }
 149
 1078150        if (!claims.TryGetValue(TokenClaimKeys.RoleId, out var rawRole) ||
 1078151            !int.TryParse(rawRole, out var roleId) ||
 1078152            !Enum.IsDefined(typeof(Roles), roleId))
 153        {
 3154            return null;
 155        }
 156
 1075157        var scopeId = TryReadGuid(claims, ScopeIdClaim, out var parsedScopeId)
 1075158            ? parsedScopeId
 1075159            : (Guid?)null;
 160
 1075161        var identity = new IdentityUser(id, roleId, scopeId, ReadOwnedScopeIds(claims))
 1075162        {
 1075163            DisplayName = claims.GetValueOrDefault(DisplayNameClaim),
 1075164            ScopePermissionClaims = ReadScopePermissionClaims(claims),
 1075165            MfaPending = claims.TryGetValue(MfaPendingClaim, out var rawMfaPending) &&
 1075166                         bool.TryParse(rawMfaPending, out var parsedMfaPending) &&
 1075167                         parsedMfaPending,
 1075168            ChallengeId = TryReadGuid(claims, ChallengeIdClaim, out var challengeId)
 1075169                ? challengeId
 1075170                : null,
 1075171            SubjectRightsOnly = claims.TryGetValue(SubjectRightsOnlyClaim, out var rawSubjectRightsOnly) &&
 1075172                                bool.TryParse(rawSubjectRightsOnly, out var parsedSubjectRightsOnly) &&
 1075173                                parsedSubjectRightsOnly,
 1075174            // An unreadable stamp is read as none. That is not a softer outcome: ActorLivenessFilter
 1075175            // refuses a person token without one.
 1075176            SecurityStamp = TryReadGuid(claims, SecurityStampClaim, out var securityStamp)
 1075177                ? securityStamp
 1075178                : null
 1075179        };
 180
 1075181        return AudienceAgrees(claims, identity) ? identity : null;
 182    }
 183
 184    /// <summary>
 185    ///     Whether the token's audience is the one its marks call for (TH-06, TH-35): the challenge
 186    ///     audience exactly when <see cref="IdentityUser.MfaPending" /> is set, the subject-rights
 187    ///     audience exactly when <see cref="IdentityUser.SubjectRightsOnly" /> is, and neither on any
 188    ///     other token.
 189    /// </summary>
 190    /// <remarks>
 191    ///     <see cref="PersonTokenValidator" /> admits only the three person audiences, but not which
 192    ///     one goes with which marks. Without this a full-audience token carrying a mark would be read
 193    ///     as marked, and a challenge-audience token without one as a sign-in. No issuer writes either,
 194    ///     so either is refused rather than read as whichever half is believed. Any other audience is
 195    ///     held to the full token's rule here; it never gets this far through the bearer path.
 196    /// </remarks>
 197    private bool AudienceAgrees(IReadOnlyDictionary<string, string> claims, IdentityUser identity)
 198    {
 1075199        var audience = claims.GetValueOrDefault(JwtRegisteredClaimNames.Aud);
 200
 1075201        return identity.MfaPending == string.Equals(audience, audiences.TwoFactorChallenge, StringComparison.Ordinal) &&
 1075202               identity.SubjectRightsOnly == string.Equals(audience, audiences.SubjectRights, StringComparison.Ordinal);
 203    }
 204
 205    private static IReadOnlyCollection<Guid> ReadOwnedScopeIds(IReadOnlyDictionary<string, string> claims)
 206    {
 1075207        if (!claims.TryGetValue(OwnedScopeIdsClaim, out var raw) || string.IsNullOrWhiteSpace(raw))
 208        {
 1049209            return [];
 210        }
 211
 212        // An unparseable entry is dropped rather than failing the whole token: the remaining owned
 213        // scopes are still a true statement about the caller, and authorization is a per-scope
 214        // check anyway.
 26215        return raw
 26216            .Split(OwnedScopeIdsSeparator, StringSplitOptions.RemoveEmptyEntries |
 26217                                           StringSplitOptions.TrimEntries)
 31218            .Select(entry => Guid.TryParse(entry, out var scopeId) ? scopeId : (Guid?)null)
 31219            .Where(scopeId => scopeId is not null)
 30220            .Select(scopeId => scopeId!.Value)
 26221            .ToList();
 222    }
 223
 224    private static IReadOnlyCollection<string> ReadScopePermissionClaims(IReadOnlyDictionary<string, string> claims)
 225    {
 1075226        if (!claims.TryGetValue(ScopePermissionClaimsClaim, out var raw) || string.IsNullOrWhiteSpace(raw))
 227        {
 1071228            return [];
 229        }
 230
 231        // A malformed permission claim is dropped rather than failing the whole token, mirroring
 232        // ReadOwnedScopeIds: the remaining identity is still a true statement about the caller, and
 233        // authorization on permissions is a separate check anyway.
 234        try
 235        {
 4236            return JsonSerializer.Deserialize<List<string>>(raw) ?? [];
 237        }
 1238        catch (JsonException)
 239        {
 1240            return [];
 241        }
 4242    }
 243
 244    private static bool TryReadGuid(
 245        IReadOnlyDictionary<string, string> claims, string key, out Guid value)
 246    {
 4306247        value = Guid.Empty;
 248
 4306249        return claims.TryGetValue(key, out var raw) && Guid.TryParse(raw, out value);
 250    }
 251}