diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca0d54..59d5732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.5] - 2026-04-12 + +### Changed + +- `CachedHostname` now uses a C# 12 primary constructor (same public construction as before). + +### Fixed + +- `RegistrationCache` loads cached PEM certificates via `X509CertificateLoader.LoadCertificate` and disposes them with `using` where certificates are parsed for expiry and host listing. +- `RegistrationCache.TryGetCachedCertificate` returns `false` when the cached entry has no private key blob, avoiding a null argument when importing key material. + ## [3.3.4] - 2026-04-01 ### Added diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs b/src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs index 881062d..85d5b31 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs @@ -1,16 +1,13 @@ namespace MaksIT.LetsEncrypt.Entities; -public class CachedHostname { - public string Hostname { get; set; } - public DateTime Expires { get; set; } - public bool IsUpcomingExpire { get; set; } - - public bool IsDisabled { get; set; } - - public CachedHostname(string hostname, DateTime expires, bool isUpcomingExpire, bool isDisabled) { - Hostname = hostname; - Expires = expires; - IsUpcomingExpire = isUpcomingExpire; - IsDisabled = isDisabled; - } +public class CachedHostname( + string hostname, + DateTime expires, + bool isUpcomingExpire, + bool isDisabled +) { + public string Hostname { get; set; } = hostname; + public DateTime Expires { get; set; } = expires; + public bool IsUpcomingExpire { get; set; } = isUpcomingExpire; + public bool IsDisabled { get; set; } = isDisabled; } diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs index 5fa2fbd..c958993 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs @@ -41,7 +41,7 @@ public class RegistrationCache { var (subject, cachedChert) = result; if (cachedChert.Cert != null && !cachedChert.IsDisabled) { - var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert)); + using var cert = X509CertificateLoader.LoadCertificate(Encoding.ASCII.GetBytes(cachedChert.Cert)); // if it is about to expire, we need to refresh if ((cert.NotAfter - DateTime.UtcNow).TotalDays < days) @@ -63,7 +63,7 @@ public class RegistrationCache { var (subject, cachedChert) = result; if (cachedChert.Cert != null) { - var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert)); + using var cert = X509CertificateLoader.LoadCertificate(Encoding.ASCII.GetBytes(cachedChert.Cert)); hosts.Add(new CachedHostname( subject, @@ -93,11 +93,14 @@ public class RegistrationCache { return false; } - var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cache.Cert)); + using var cert = X509CertificateLoader.LoadCertificate(Encoding.ASCII.GetBytes(cache.Cert)); if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 30) return false; + if (cache.Private is null) + return false; + var rsa = new RSACryptoServiceProvider(4096); rsa.ImportCspBlob(cache.Private); @@ -106,6 +109,7 @@ public class RegistrationCache { Private = rsa.ExportCspBlob(true), PrivatePem = rsa.ExportRSAPrivateKeyPem() }; + return true; } @@ -128,13 +132,15 @@ public class RegistrationCache { if (CachedCerts == null) return result; - foreach (var kvp in CachedCerts) { + foreach (var kvp in CachedCerts) + { var hostname = kvp.Key; var cert = kvp.Value; if (!string.IsNullOrEmpty(cert.Cert) && !string.IsNullOrEmpty(cert.PrivatePem)) { result[hostname] = $"{cert.Cert}\n{cert.PrivatePem}"; } } + return result; } } diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/State.cs b/src/LetsEncrypt/Entities/LetsEncrypt/State.cs index 04e088a..53996cc 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/State.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/State.cs @@ -1,6 +1,5 @@ using MaksIT.Core.Security.JWK; using MaksIT.LetsEncrypt.Models.Responses; -using MaksIT.LetsEncrypt.Services; using System.Security.Cryptography; diff --git a/src/MaksIT.Models/LetsEncryptServer/Identity/Login/LoginResponse.cs b/src/MaksIT.Models/LetsEncryptServer/Identity/Login/LoginResponse.cs index 32c66f0..290055f 100644 --- a/src/MaksIT.Models/LetsEncryptServer/Identity/Login/LoginResponse.cs +++ b/src/MaksIT.Models/LetsEncryptServer/Identity/Login/LoginResponse.cs @@ -9,4 +9,7 @@ public class LoginResponse : ResponseModelBase { public required DateTime ExpiresAt { get; set; } public required string RefreshToken { get; set; } public required DateTime RefreshTokenExpiresAt { get; set; } + + /// Actual login username; use this for display so it is not replaced by a display name (e.g. "Organization Admin") from the JWT name claim. + public string? Username { get; set; } } \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/localStorage/identity.ts b/src/MaksIT.WebUI/src/localStorage/identity.ts index e4fe4e9..39da5c5 100644 --- a/src/MaksIT.WebUI/src/localStorage/identity.ts +++ b/src/MaksIT.WebUI/src/localStorage/identity.ts @@ -1,19 +1,51 @@ import { LoginResponse } from '../models/identity/login/LoginResponse' -const readIdentity = () => { - const json = localStorage.getItem('identity') +/** + * Maps API/localStorage payloads to a single camelCase LoginResponse so stored JSON matches across apps. + */ +const normalizeLoginResponse = (raw: unknown): LoginResponse | undefined => { + if (raw == null || typeof raw !== 'object') return undefined + const r = raw as Record + const str = (camel: keyof LoginResponse, pascal: string): string => { + const v = r[camel as string] ?? r[pascal] + if (v == null) return '' + return typeof v === 'string' ? v : String(v) + } + const token = str('token', 'Token') + const refreshToken = str('refreshToken', 'RefreshToken') + if (!token || !refreshToken) return undefined - if (!json) return undefined + const out: LoginResponse = { + tokenType: str('tokenType', 'TokenType') || 'Bearer', + token, + expiresAt: str('expiresAt', 'ExpiresAt'), + refreshToken, + refreshTokenExpiresAt: str('refreshTokenExpiresAt', 'RefreshTokenExpiresAt'), + } + const u = r.username ?? r.Username + if (typeof u === 'string' && u.length > 0) + out.username = u - return JSON.parse(json) as LoginResponse + return out } -const writeIdentity = (identity: LoginResponse) => { - localStorage.setItem('identity', JSON.stringify(identity)) +const readIdentity = (): LoginResponse | undefined => { + const json = localStorage.getItem('identity') + if (!json) return undefined + try { + return normalizeLoginResponse(JSON.parse(json) as unknown) + } catch { + return undefined + } +} + +const writeIdentity = (identity: LoginResponse | unknown) => { + const n = normalizeLoginResponse(identity) + if (n) localStorage.setItem('identity', JSON.stringify(n)) } const removeIdentity = () => { localStorage.removeItem('identity') } -export { readIdentity, writeIdentity, removeIdentity } \ No newline at end of file +export { readIdentity, writeIdentity, removeIdentity, normalizeLoginResponse } diff --git a/src/MaksIT.WebUI/src/models/identity/login/LoginResponse.ts b/src/MaksIT.WebUI/src/models/identity/login/LoginResponse.ts index 224099d..c411fc8 100644 --- a/src/MaksIT.WebUI/src/models/identity/login/LoginResponse.ts +++ b/src/MaksIT.WebUI/src/models/identity/login/LoginResponse.ts @@ -4,4 +4,6 @@ export interface LoginResponse { expiresAt: string refreshToken: string refreshTokenExpiresAt: string + /** Actual login username from the server; use for display so it is not replaced by a display name (e.g. "Organization Admin") from the JWT. */ + username?: string } \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/redux/slices/identitySlice.ts b/src/MaksIT.WebUI/src/redux/slices/identitySlice.ts index a7da507..e162d23 100644 --- a/src/MaksIT.WebUI/src/redux/slices/identitySlice.ts +++ b/src/MaksIT.WebUI/src/redux/slices/identitySlice.ts @@ -7,25 +7,14 @@ import { LoginRequest } from '../../models/identity/login/LoginRequest' import { ApiRoutes, GetApiRoute } from '../../AppMap' import { LogoutRequest } from '../../models/identity/logout/LogoutRequest' import { LogoutResponse } from '../../models/identity/logout/LogoutResponse' -import { readIdentity, removeIdentity, writeIdentity } from '../../localStorage/identity' +import { readIdentity, removeIdentity, writeIdentity, normalizeLoginResponse } from '../../localStorage/identity' import { RefreshTokenRequest } from '../../models/identity/login/RefreshTokenRequest' import { jwtDecode } from 'jwt-decode' import { Claims } from '../../models/identity/Claims' -import { enumToArr, parseAclEntries } from '../../functions' -import { Role } from '../../models/identity/Role' -import { AclEntry } from '../../functions/acl/parseAclEntry' - -interface IdentityRole { - value: string | number, - label: string -} interface Identity extends LoginResponse { userId?: string, username?: string - roles?: IdentityRole [] - isGlobalAdmin: boolean - acls?: AclEntry [] } interface IdentityState { @@ -36,24 +25,6 @@ interface IdentityState { hydrated: boolean } -/** API JSON may be camelCase or PascalCase; normalize so Authorization and axios see stable keys. */ -const normalizeLoginResponse = (raw: LoginResponse | undefined): LoginResponse | undefined => { - if (!raw) return undefined - const r = raw as Record - const str = (camel: keyof LoginResponse, pascal: string) => { - const v = r[camel] ?? r[pascal] - if (v == null) return '' - return typeof v === 'string' ? v : String(v) - } - return { - tokenType: str('tokenType', 'TokenType'), - token: str('token', 'Token'), - expiresAt: str('expiresAt', 'ExpiresAt'), - refreshToken: str('refreshToken', 'RefreshToken'), - refreshTokenExpiresAt: str('refreshTokenExpiresAt', 'RefreshTokenExpiresAt'), - } -} - const initialState: IdentityState = { identity: null, showUserOffcanvas: false, @@ -104,62 +75,26 @@ const refreshJwt = createAsyncThunk( ) const enrichStateWithJwtContent = (token: string, identity: Identity) => { - const jwtContent = jwtDecode(token) as Record + let jwtContent: Record + try { + jwtContent = jwtDecode(token) as Record + } catch { + return + } if (jwtContent) { if (jwtContent[Claims.NameIdentifier]) identity.userId = jwtContent[Claims.NameIdentifier] as string + // Keep the original username: prefer username from login/refresh response, then JWT claims (do not replace with a display name like "Organization Admin" from the name claim) if (identity.username == null || identity.username?.trim() === '') { const nameClaim = jwtContent[Claims.Name] as string | undefined const usernameClaim = (jwtContent['username'] ?? jwtContent['preferred_username']) as string | undefined identity.username = (usernameClaim?.trim()) ? usernameClaim : nameClaim } - - if (jwtContent[Claims.Role]) { - - const appKnownRoles = enumToArr(Role)?.map(item => { - return { - value: item.value, - label: item.displayValue - } - }) - - const jwtRoles: string[] = Array.isArray(jwtContent[Claims.Role]) - ? jwtContent[Claims.Role] as string[] - : jwtContent[Claims.Role] - ? [jwtContent[Claims.Role] as string] - : [] - - const identityRoles: IdentityRole [] = [] - jwtRoles.forEach(identityRole => { - const foundRole = appKnownRoles.find(role => role.label === identityRole) - if (foundRole) { - identityRoles.push(foundRole) - } - }) - - identity.roles = identityRoles - } - - if (jwtContent[Claims.AclEntry]) { - const jwtAcls: string[] = Array.isArray(jwtContent[Claims.AclEntry]) - ? jwtContent[Claims.AclEntry] as string[] - : jwtContent[Claims.AclEntry] - ? [jwtContent[Claims.AclEntry] as string] - : [] - - if (jwtAcls?.includes('global:admin') ?? false) { - jwtAcls.splice(jwtAcls.indexOf('global:admin'), 1) - identity.isGlobalAdmin = true - } - else { - identity.isGlobalAdmin = false - } - - identity.acls = parseAclEntries(jwtAcls) - } } + + console.log('Enriched identity:', identity) } const identitySlice = createSlice({ @@ -167,13 +102,10 @@ const identitySlice = createSlice({ initialState, reducers: { setIdentityFromLocalStorage: (state) => { - const raw = readIdentity() - const identity = normalizeLoginResponse(raw) + const identity = readIdentity() - if (identity?.token && identity.refreshTokenExpiresAt) { - writeIdentity(identity) + if (identity) { state.identity = { - isGlobalAdmin: false, ...identity } enrichStateWithJwtContent(identity.token, state.identity) @@ -204,15 +136,14 @@ const identitySlice = createSlice({ }) .addCase(login.fulfilled, (state, action: PayloadAction) => { state.status = 'idle' - const payload = normalizeLoginResponse(action.payload) - if (payload?.token && payload.refreshTokenExpiresAt) { + const normalized = normalizeLoginResponse(action.payload) + if (normalized) { state.identity = { - isGlobalAdmin: false, - ...payload + ...normalized } - writeIdentity(payload) + writeIdentity(normalized) - enrichStateWithJwtContent(payload.token, state.identity) + enrichStateWithJwtContent(normalized.token, state.identity) } }) .addCase(login.rejected, (state) => { @@ -241,17 +172,17 @@ const identitySlice = createSlice({ .addCase(refreshJwt.fulfilled, (state, action: PayloadAction) => { state.status = 'idle' - const payload = normalizeLoginResponse(action.payload) - if (payload?.token && payload.refreshTokenExpiresAt) { + const normalized = normalizeLoginResponse(action.payload) + if (normalized) { state.identity = { - isGlobalAdmin: false, - ...payload + ...normalized } - writeIdentity(payload) + writeIdentity(normalized) - enrichStateWithJwtContent(payload.token, state.identity) + enrichStateWithJwtContent(normalized.token, state.identity) } else { + // Refresh API returned error (e.g. 401 Invalid refresh token); treat as logged out state.identity = null state.showUserOffcanvas = false removeIdentity() diff --git a/src/MaksIT.Webapi/MaksIT.Webapi.csproj b/src/MaksIT.Webapi/MaksIT.Webapi.csproj index 519d216..3d4f96f 100644 --- a/src/MaksIT.Webapi/MaksIT.Webapi.csproj +++ b/src/MaksIT.Webapi/MaksIT.Webapi.csproj @@ -1,7 +1,7 @@ - 3.3.4 + 3.3.5 net10.0 enable enable diff --git a/src/MaksIT.Webapi/Services/IdentityService.cs b/src/MaksIT.Webapi/Services/IdentityService.cs index b11bba2..7171744 100644 --- a/src/MaksIT.Webapi/Services/IdentityService.cs +++ b/src/MaksIT.Webapi/Services/IdentityService.cs @@ -130,7 +130,8 @@ public class IdentityService( Token = tokenDomain.Token, ExpiresAt = claims.ExpiresAt.Value, RefreshToken = tokenDomain.RefreshToken, - RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt + RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt, + Username = user.Name }; return Result.Ok(response); @@ -166,7 +167,8 @@ public class IdentityService( Token = tokenDomain.Token, ExpiresAt = tokenDomain.ExpiresAt, RefreshToken = tokenDomain.RefreshToken, - RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt + RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt, + Username = user.Name }); } @@ -212,7 +214,8 @@ public class IdentityService( Token = tokenDomain.Token, ExpiresAt = claims.ExpiresAt.Value, RefreshToken = tokenDomain.RefreshToken, - RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt + RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt, + Username = user.Name }); } diff --git a/src/docker-compose.dcproj b/src/docker-compose.dcproj index 520a809..1494c46 100644 --- a/src/docker-compose.dcproj +++ b/src/docker-compose.dcproj @@ -6,8 +6,7 @@ False 0233e43f-435d-4309-b20c-ecd4bfbd2e63 LaunchBrowser - - {Scheme}://localhost:{ServicePort}/swagger + {Scheme}://localhost:{ServicePort} reverseproxy