(refactor): LetsEncrypt cache entities (primary ctor, cert loader, null guard)

This commit is contained in:
Maksym Sadovnychyy 2026-04-12 21:00:19 +02:00
parent f70742cf18
commit 685a174806
11 changed files with 106 additions and 123 deletions

View File

@ -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). 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 ## [3.3.4] - 2026-04-01
### Added ### Added

View File

@ -1,16 +1,13 @@
namespace MaksIT.LetsEncrypt.Entities; namespace MaksIT.LetsEncrypt.Entities;
public class CachedHostname { public class CachedHostname(
public string Hostname { get; set; } string hostname,
public DateTime Expires { get; set; } DateTime expires,
public bool IsUpcomingExpire { get; set; } bool isUpcomingExpire,
bool isDisabled
public bool IsDisabled { get; set; } ) {
public string Hostname { get; set; } = hostname;
public CachedHostname(string hostname, DateTime expires, bool isUpcomingExpire, bool isDisabled) { public DateTime Expires { get; set; } = expires;
Hostname = hostname; public bool IsUpcomingExpire { get; set; } = isUpcomingExpire;
Expires = expires; public bool IsDisabled { get; set; } = isDisabled;
IsUpcomingExpire = isUpcomingExpire;
IsDisabled = isDisabled;
}
} }

View File

@ -41,7 +41,7 @@ public class RegistrationCache {
var (subject, cachedChert) = result; var (subject, cachedChert) = result;
if (cachedChert.Cert != null && !cachedChert.IsDisabled) { 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 it is about to expire, we need to refresh
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < days) if ((cert.NotAfter - DateTime.UtcNow).TotalDays < days)
@ -63,7 +63,7 @@ public class RegistrationCache {
var (subject, cachedChert) = result; var (subject, cachedChert) = result;
if (cachedChert.Cert != null) { 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( hosts.Add(new CachedHostname(
subject, subject,
@ -93,11 +93,14 @@ public class RegistrationCache {
return false; 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) if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 30)
return false; return false;
if (cache.Private is null)
return false;
var rsa = new RSACryptoServiceProvider(4096); var rsa = new RSACryptoServiceProvider(4096);
rsa.ImportCspBlob(cache.Private); rsa.ImportCspBlob(cache.Private);
@ -106,6 +109,7 @@ public class RegistrationCache {
Private = rsa.ExportCspBlob(true), Private = rsa.ExportCspBlob(true),
PrivatePem = rsa.ExportRSAPrivateKeyPem() PrivatePem = rsa.ExportRSAPrivateKeyPem()
}; };
return true; return true;
} }
@ -128,13 +132,15 @@ public class RegistrationCache {
if (CachedCerts == null) if (CachedCerts == null)
return result; return result;
foreach (var kvp in CachedCerts) { foreach (var kvp in CachedCerts)
{
var hostname = kvp.Key; var hostname = kvp.Key;
var cert = kvp.Value; var cert = kvp.Value;
if (!string.IsNullOrEmpty(cert.Cert) && !string.IsNullOrEmpty(cert.PrivatePem)) { if (!string.IsNullOrEmpty(cert.Cert) && !string.IsNullOrEmpty(cert.PrivatePem)) {
result[hostname] = $"{cert.Cert}\n{cert.PrivatePem}"; result[hostname] = $"{cert.Cert}\n{cert.PrivatePem}";
} }
} }
return result; return result;
} }
} }

View File

@ -1,6 +1,5 @@
using MaksIT.Core.Security.JWK; using MaksIT.Core.Security.JWK;
using MaksIT.LetsEncrypt.Models.Responses; using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.LetsEncrypt.Services;
using System.Security.Cryptography; using System.Security.Cryptography;

View File

@ -9,4 +9,7 @@ public class LoginResponse : ResponseModelBase {
public required DateTime ExpiresAt { get; set; } public required DateTime ExpiresAt { get; set; }
public required string RefreshToken { get; set; } public required string RefreshToken { get; set; }
public required DateTime RefreshTokenExpiresAt { get; set; } public required DateTime RefreshTokenExpiresAt { get; set; }
/// <summary>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.</summary>
public string? Username { get; set; }
} }

View File

@ -1,19 +1,51 @@
import { LoginResponse } from '../models/identity/login/LoginResponse' 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<string, unknown>
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) => { const readIdentity = (): LoginResponse | undefined => {
localStorage.setItem('identity', JSON.stringify(identity)) 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 = () => { const removeIdentity = () => {
localStorage.removeItem('identity') localStorage.removeItem('identity')
} }
export { readIdentity, writeIdentity, removeIdentity } export { readIdentity, writeIdentity, removeIdentity, normalizeLoginResponse }

View File

@ -4,4 +4,6 @@ export interface LoginResponse {
expiresAt: string expiresAt: string
refreshToken: string refreshToken: string
refreshTokenExpiresAt: 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
} }

View File

@ -7,25 +7,14 @@ import { LoginRequest } from '../../models/identity/login/LoginRequest'
import { ApiRoutes, GetApiRoute } from '../../AppMap' import { ApiRoutes, GetApiRoute } from '../../AppMap'
import { LogoutRequest } from '../../models/identity/logout/LogoutRequest' import { LogoutRequest } from '../../models/identity/logout/LogoutRequest'
import { LogoutResponse } from '../../models/identity/logout/LogoutResponse' 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 { RefreshTokenRequest } from '../../models/identity/login/RefreshTokenRequest'
import { jwtDecode } from 'jwt-decode' import { jwtDecode } from 'jwt-decode'
import { Claims } from '../../models/identity/Claims' 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 { interface Identity extends LoginResponse {
userId?: string, userId?: string,
username?: string username?: string
roles?: IdentityRole []
isGlobalAdmin: boolean
acls?: AclEntry []
} }
interface IdentityState { interface IdentityState {
@ -36,24 +25,6 @@ interface IdentityState {
hydrated: boolean 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<string, unknown>
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 = { const initialState: IdentityState = {
identity: null, identity: null,
showUserOffcanvas: false, showUserOffcanvas: false,
@ -104,62 +75,26 @@ const refreshJwt = createAsyncThunk(
) )
const enrichStateWithJwtContent = (token: string, identity: Identity) => { const enrichStateWithJwtContent = (token: string, identity: Identity) => {
const jwtContent = jwtDecode(token) as Record<string, unknown> let jwtContent: Record<string, unknown>
try {
jwtContent = jwtDecode(token) as Record<string, unknown>
} catch {
return
}
if (jwtContent) { if (jwtContent) {
if (jwtContent[Claims.NameIdentifier]) if (jwtContent[Claims.NameIdentifier])
identity.userId = jwtContent[Claims.NameIdentifier] as string 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() === '') { if (identity.username == null || identity.username?.trim() === '') {
const nameClaim = jwtContent[Claims.Name] as string | undefined const nameClaim = jwtContent[Claims.Name] as string | undefined
const usernameClaim = (jwtContent['username'] ?? jwtContent['preferred_username']) as string | undefined const usernameClaim = (jwtContent['username'] ?? jwtContent['preferred_username']) as string | undefined
identity.username = (usernameClaim?.trim()) ? usernameClaim : nameClaim 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]) { console.log('Enriched identity:', identity)
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)
}
}
} }
const identitySlice = createSlice({ const identitySlice = createSlice({
@ -167,13 +102,10 @@ const identitySlice = createSlice({
initialState, initialState,
reducers: { reducers: {
setIdentityFromLocalStorage: (state) => { setIdentityFromLocalStorage: (state) => {
const raw = readIdentity() const identity = readIdentity()
const identity = normalizeLoginResponse(raw)
if (identity?.token && identity.refreshTokenExpiresAt) { if (identity) {
writeIdentity(identity)
state.identity = { state.identity = {
isGlobalAdmin: false,
...identity ...identity
} }
enrichStateWithJwtContent(identity.token, state.identity) enrichStateWithJwtContent(identity.token, state.identity)
@ -204,15 +136,14 @@ const identitySlice = createSlice({
}) })
.addCase(login.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => { .addCase(login.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => {
state.status = 'idle' state.status = 'idle'
const payload = normalizeLoginResponse(action.payload) const normalized = normalizeLoginResponse(action.payload)
if (payload?.token && payload.refreshTokenExpiresAt) { if (normalized) {
state.identity = { state.identity = {
isGlobalAdmin: false, ...normalized
...payload
} }
writeIdentity(payload) writeIdentity(normalized)
enrichStateWithJwtContent(payload.token, state.identity) enrichStateWithJwtContent(normalized.token, state.identity)
} }
}) })
.addCase(login.rejected, (state) => { .addCase(login.rejected, (state) => {
@ -241,17 +172,17 @@ const identitySlice = createSlice({
.addCase(refreshJwt.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => { .addCase(refreshJwt.fulfilled, (state, action: PayloadAction<LoginResponse | undefined>) => {
state.status = 'idle' state.status = 'idle'
const payload = normalizeLoginResponse(action.payload) const normalized = normalizeLoginResponse(action.payload)
if (payload?.token && payload.refreshTokenExpiresAt) { if (normalized) {
state.identity = { state.identity = {
isGlobalAdmin: false, ...normalized
...payload
} }
writeIdentity(payload) writeIdentity(normalized)
enrichStateWithJwtContent(payload.token, state.identity) enrichStateWithJwtContent(normalized.token, state.identity)
} }
else { else {
// Refresh API returned error (e.g. 401 Invalid refresh token); treat as logged out
state.identity = null state.identity = null
state.showUserOffcanvas = false state.showUserOffcanvas = false
removeIdentity() removeIdentity()

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<Version>3.3.4</Version> <Version>3.3.5</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -130,7 +130,8 @@ public class IdentityService(
Token = tokenDomain.Token, Token = tokenDomain.Token,
ExpiresAt = claims.ExpiresAt.Value, ExpiresAt = claims.ExpiresAt.Value,
RefreshToken = tokenDomain.RefreshToken, RefreshToken = tokenDomain.RefreshToken,
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt,
Username = user.Name
}; };
return Result<LoginResponse?>.Ok(response); return Result<LoginResponse?>.Ok(response);
@ -166,7 +167,8 @@ public class IdentityService(
Token = tokenDomain.Token, Token = tokenDomain.Token,
ExpiresAt = tokenDomain.ExpiresAt, ExpiresAt = tokenDomain.ExpiresAt,
RefreshToken = tokenDomain.RefreshToken, RefreshToken = tokenDomain.RefreshToken,
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt,
Username = user.Name
}); });
} }
@ -212,7 +214,8 @@ public class IdentityService(
Token = tokenDomain.Token, Token = tokenDomain.Token,
ExpiresAt = claims.ExpiresAt.Value, ExpiresAt = claims.ExpiresAt.Value,
RefreshToken = tokenDomain.RefreshToken, RefreshToken = tokenDomain.RefreshToken,
RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt RefreshTokenExpiresAt = tokenDomain.RefreshTokenExpiresAt,
Username = user.Name
}); });
} }

View File

@ -6,8 +6,7 @@
<DockerPublishLocally>False</DockerPublishLocally> <DockerPublishLocally>False</DockerPublishLocally>
<ProjectGuid>0233e43f-435d-4309-b20c-ecd4bfbd2e63</ProjectGuid> <ProjectGuid>0233e43f-435d-4309-b20c-ecd4bfbd2e63</ProjectGuid>
<DockerLaunchAction>LaunchBrowser</DockerLaunchAction> <DockerLaunchAction>LaunchBrowser</DockerLaunchAction>
<!-- reverseproxy is the only service with host port mappings; server/client are internal-only. Swagger is served via YARP at /swagger. --> <DockerServiceUrl>{Scheme}://localhost:{ServicePort}</DockerServiceUrl>
<DockerServiceUrl>{Scheme}://localhost:{ServicePort}/swagger</DockerServiceUrl>
<DockerServiceName>reverseproxy</DockerServiceName> <DockerServiceName>reverseproxy</DockerServiceName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>