| | | 1 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 2 | | using ArturRios.Heimdall.Shared.Security; |
| | | 3 | | using ArturRios.Util.WebApi.Security.Constants; |
| | | 4 | | using ArturRios.Util.WebApi.Security.Interfaces; |
| | | 5 | | using Microsoft.IdentityModel.JsonWebTokens; |
| | | 6 | | using System.Text.Json; |
| | | 7 | | |
| | | 8 | | namespace 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> |
| | 1621 | 25 | | public 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 | | { |
| | 885 | 69 | | var claims = new Dictionary<string, string> |
| | 885 | 70 | | { |
| | 885 | 71 | | [TokenClaimKeys.Id] = user.Id.ToString(), |
| | 885 | 72 | | [TokenClaimKeys.RoleId] = user.RoleId.ToString() |
| | 885 | 73 | | }; |
| | | 74 | | |
| | 885 | 75 | | if (user is not IdentityUser identityUser) |
| | | 76 | | { |
| | 0 | 77 | | return claims; |
| | | 78 | | } |
| | | 79 | | |
| | 885 | 80 | | if (!string.IsNullOrWhiteSpace(identityUser.DisplayName)) |
| | | 81 | | { |
| | 79 | 82 | | 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. |
| | 885 | 88 | | if (identityUser.ScopeId is not null) |
| | | 89 | | { |
| | 43 | 90 | | claims[ScopeIdClaim] = identityUser.ScopeId.Value.ToString(); |
| | | 91 | | } |
| | | 92 | | |
| | 885 | 93 | | if (identityUser.OwnedScopeIds.Count > 0) |
| | | 94 | | { |
| | 25 | 95 | | claims[OwnedScopeIdsClaim] = string.Join( |
| | 25 | 96 | | 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. |
| | 885 | 102 | | if (identityUser.ScopePermissionClaims.Count > 0) |
| | | 103 | | { |
| | 3 | 104 | | claims[ScopePermissionClaimsClaim] = |
| | 3 | 105 | | 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. |
| | 885 | 110 | | if (identityUser.MfaPending) |
| | | 111 | | { |
| | 88 | 112 | | claims[MfaPendingClaim] = "true"; |
| | | 113 | | } |
| | | 114 | | |
| | 885 | 115 | | if (identityUser.ChallengeId is not null) |
| | | 116 | | { |
| | 88 | 117 | | claims[ChallengeIdClaim] = identityUser.ChallengeId.Value.ToString(); |
| | | 118 | | } |
| | | 119 | | |
| | | 120 | | // NFR-24: likewise present only when true. |
| | 885 | 121 | | if (identityUser.SubjectRightsOnly) |
| | | 122 | | { |
| | 25 | 123 | | claims[SubjectRightsOnlyClaim] = "true"; |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | // FR-AU-11: omitted rather than emitted empty when there is no stamp, like the scope claims. |
| | 885 | 127 | | if (identityUser.SecurityStamp is not null) |
| | | 128 | | { |
| | 791 | 129 | | claims[SecurityStampClaim] = identityUser.SecurityStamp.Value.ToString(); |
| | | 130 | | } |
| | | 131 | | |
| | 885 | 132 | | 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. |
| | 1083 | 140 | | if (claims.ContainsKey(ApplicationTokenClaims.TokenKind)) |
| | | 141 | | { |
| | 2 | 142 | | return null; |
| | | 143 | | } |
| | | 144 | | |
| | 1081 | 145 | | if (!TryReadGuid(claims, TokenClaimKeys.Id, out var id)) |
| | | 146 | | { |
| | 3 | 147 | | return null; |
| | | 148 | | } |
| | | 149 | | |
| | 1078 | 150 | | if (!claims.TryGetValue(TokenClaimKeys.RoleId, out var rawRole) || |
| | 1078 | 151 | | !int.TryParse(rawRole, out var roleId) || |
| | 1078 | 152 | | !Enum.IsDefined(typeof(Roles), roleId)) |
| | | 153 | | { |
| | 3 | 154 | | return null; |
| | | 155 | | } |
| | | 156 | | |
| | 1075 | 157 | | var scopeId = TryReadGuid(claims, ScopeIdClaim, out var parsedScopeId) |
| | 1075 | 158 | | ? parsedScopeId |
| | 1075 | 159 | | : (Guid?)null; |
| | | 160 | | |
| | 1075 | 161 | | var identity = new IdentityUser(id, roleId, scopeId, ReadOwnedScopeIds(claims)) |
| | 1075 | 162 | | { |
| | 1075 | 163 | | DisplayName = claims.GetValueOrDefault(DisplayNameClaim), |
| | 1075 | 164 | | ScopePermissionClaims = ReadScopePermissionClaims(claims), |
| | 1075 | 165 | | MfaPending = claims.TryGetValue(MfaPendingClaim, out var rawMfaPending) && |
| | 1075 | 166 | | bool.TryParse(rawMfaPending, out var parsedMfaPending) && |
| | 1075 | 167 | | parsedMfaPending, |
| | 1075 | 168 | | ChallengeId = TryReadGuid(claims, ChallengeIdClaim, out var challengeId) |
| | 1075 | 169 | | ? challengeId |
| | 1075 | 170 | | : null, |
| | 1075 | 171 | | SubjectRightsOnly = claims.TryGetValue(SubjectRightsOnlyClaim, out var rawSubjectRightsOnly) && |
| | 1075 | 172 | | bool.TryParse(rawSubjectRightsOnly, out var parsedSubjectRightsOnly) && |
| | 1075 | 173 | | parsedSubjectRightsOnly, |
| | 1075 | 174 | | // An unreadable stamp is read as none. That is not a softer outcome: ActorLivenessFilter |
| | 1075 | 175 | | // refuses a person token without one. |
| | 1075 | 176 | | SecurityStamp = TryReadGuid(claims, SecurityStampClaim, out var securityStamp) |
| | 1075 | 177 | | ? securityStamp |
| | 1075 | 178 | | : null |
| | 1075 | 179 | | }; |
| | | 180 | | |
| | 1075 | 181 | | 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 | | { |
| | 1075 | 199 | | var audience = claims.GetValueOrDefault(JwtRegisteredClaimNames.Aud); |
| | | 200 | | |
| | 1075 | 201 | | return identity.MfaPending == string.Equals(audience, audiences.TwoFactorChallenge, StringComparison.Ordinal) && |
| | 1075 | 202 | | identity.SubjectRightsOnly == string.Equals(audience, audiences.SubjectRights, StringComparison.Ordinal); |
| | | 203 | | } |
| | | 204 | | |
| | | 205 | | private static IReadOnlyCollection<Guid> ReadOwnedScopeIds(IReadOnlyDictionary<string, string> claims) |
| | | 206 | | { |
| | 1075 | 207 | | if (!claims.TryGetValue(OwnedScopeIdsClaim, out var raw) || string.IsNullOrWhiteSpace(raw)) |
| | | 208 | | { |
| | 1049 | 209 | | 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. |
| | 26 | 215 | | return raw |
| | 26 | 216 | | .Split(OwnedScopeIdsSeparator, StringSplitOptions.RemoveEmptyEntries | |
| | 26 | 217 | | StringSplitOptions.TrimEntries) |
| | 31 | 218 | | .Select(entry => Guid.TryParse(entry, out var scopeId) ? scopeId : (Guid?)null) |
| | 31 | 219 | | .Where(scopeId => scopeId is not null) |
| | 30 | 220 | | .Select(scopeId => scopeId!.Value) |
| | 26 | 221 | | .ToList(); |
| | | 222 | | } |
| | | 223 | | |
| | | 224 | | private static IReadOnlyCollection<string> ReadScopePermissionClaims(IReadOnlyDictionary<string, string> claims) |
| | | 225 | | { |
| | 1075 | 226 | | if (!claims.TryGetValue(ScopePermissionClaimsClaim, out var raw) || string.IsNullOrWhiteSpace(raw)) |
| | | 227 | | { |
| | 1071 | 228 | | 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 | | { |
| | 4 | 236 | | return JsonSerializer.Deserialize<List<string>>(raw) ?? []; |
| | | 237 | | } |
| | 1 | 238 | | catch (JsonException) |
| | | 239 | | { |
| | 1 | 240 | | return []; |
| | | 241 | | } |
| | 4 | 242 | | } |
| | | 243 | | |
| | | 244 | | private static bool TryReadGuid( |
| | | 245 | | IReadOnlyDictionary<string, string> claims, string key, out Guid value) |
| | | 246 | | { |
| | 4306 | 247 | | value = Guid.Empty; |
| | | 248 | | |
| | 4306 | 249 | | return claims.TryGetValue(key, out var raw) && Guid.TryParse(raw, out value); |
| | | 250 | | } |
| | | 251 | | } |