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