| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Services; |
| | | 3 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 4 | | using ArturRios.Jwt; |
| | | 5 | | using ArturRios.Util.WebApi.Security.Interfaces; |
| | | 6 | | using Microsoft.EntityFrameworkCore; |
| | | 7 | | |
| | | 8 | | namespace 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> |
| | 206 | 28 | | public class JwtAuthTokenIssuer( |
| | 206 | 29 | | JwtConfiguration configuration, |
| | 206 | 30 | | PersonTokenAudiences audiences, |
| | 206 | 31 | | JwtHandler jwtHandler, |
| | 206 | 32 | | IAuthenticatedUserMapper mapper, |
| | 206 | 33 | | IAsyncReadOnlyRepository<ScopePermission, long> permissionReader) : IAuthTokenIssuer |
| | | 34 | | { |
| | | 35 | | public async Task<AuthToken> IssueAsync(AuthTokenSubject subject) |
| | | 36 | | { |
| | 77 | 37 | | 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. |
| | 77 | 41 | | var user = new IdentityUser( |
| | 77 | 42 | | subject.PersonId, subject.RoleId, subject.ScopeId, subject.OwnedScopeIds) |
| | 77 | 43 | | { |
| | 77 | 44 | | DisplayName = subject.DisplayName, |
| | 77 | 45 | | ScopePermissionClaims = permissionClaims, |
| | 77 | 46 | | SecurityStamp = subject.SecurityStamp |
| | 77 | 47 | | }; |
| | | 48 | | |
| | 77 | 49 | | var tokenConfiguration = configuration with { Claims = mapper.ToClaims(user) }; |
| | | 50 | | |
| | 77 | 51 | | return new AuthToken( |
| | 77 | 52 | | jwtHandler.CreateToken(tokenConfiguration), |
| | 77 | 53 | | DateTime.UtcNow.AddSeconds(configuration.ExpirationInSeconds)); |
| | 77 | 54 | | } |
| | | 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). |
| | 12 | 62 | | var user = new IdentityUser(subjectId, roleId) { SubjectRightsOnly = true, SecurityStamp = securityStamp }; |
| | | 63 | | |
| | 12 | 64 | | var tokenConfiguration = configuration with |
| | 12 | 65 | | { |
| | 12 | 66 | | Audience = audiences.SubjectRights, |
| | 12 | 67 | | Claims = mapper.ToClaims(user) |
| | 12 | 68 | | }; |
| | | 69 | | |
| | 12 | 70 | | return Task.FromResult(new AuthToken( |
| | 12 | 71 | | jwtHandler.CreateToken(tokenConfiguration), |
| | 12 | 72 | | 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 | | { |
| | 77 | 83 | | var scopePublicIds = subject.OwnedScopeIds.ToList(); |
| | | 84 | | |
| | 77 | 85 | | if (subject.ScopeId is not null) |
| | | 86 | | { |
| | 21 | 87 | | scopePublicIds.Add(subject.ScopeId.Value); |
| | | 88 | | } |
| | | 89 | | |
| | 77 | 90 | | if (scopePublicIds.Count == 0) |
| | | 91 | | { |
| | 53 | 92 | | return []; |
| | | 93 | | } |
| | | 94 | | |
| | 24 | 95 | | return await permissionReader.Query() |
| | 24 | 96 | | .Where(permission => scopePublicIds.Contains(permission.Scope.PublicId) |
| | 24 | 97 | | && !permission.Scope.IsDeleted |
| | 24 | 98 | | && !permission.IsDeleted |
| | 24 | 99 | | && permission.IncludeAsJwtClaim) |
| | 24 | 100 | | .Select(permission => permission.Name) |
| | 24 | 101 | | .Distinct() |
| | 24 | 102 | | .ToListAsync(); |
| | 77 | 103 | | } |
| | | 104 | | } |