Merge pull request 'postit: allow self-signed OIDC TLS in Development' (#9) from fix/OIDC-debugging into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled

Reviewed-on: #9
This commit is contained in:
Paul Schneider 2026-08-02 21:32:49 +01:00
commit 573c12fe30
17 changed files with 207 additions and 69 deletions

View file

@ -12,8 +12,10 @@
"DOTNET", "DOTNET",
"ecdsa", "ecdsa",
"envsubst", "envsubst",
"Hsts",
"Newtonsoft", "Newtonsoft",
"Npgsql", "Npgsql",
"PKCE",
"postit", "postit",
"pschneider", "pschneider",
"SLNDIR", "SLNDIR",

View file

@ -25,7 +25,7 @@ public partial class App : Application
/// <c>DataValidationErrors.SetErrors</c>. /// <c>DataValidationErrors.SetErrors</c>.
/// </summary> /// </summary>
public IServiceProvider? Services { get; private set; } public IServiceProvider? Services { get; private set; }
private MainWindow window;
public App() public App()
{ {
} }
@ -137,7 +137,7 @@ public partial class App : Application
var homePage = provider.GetRequiredService<HomePage>(); var homePage = provider.GetRequiredService<HomePage>();
homePage.DataContext = provider.GetRequiredService<HomePageViewModel>(); homePage.DataContext = provider.GetRequiredService<HomePageViewModel>();
var window = new MainWindow(); window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus; window.SessionBanner.DataContext = sessionStatus;
// Build the navigation stack from scratch: HomePage is the // Build the navigation stack from scratch: HomePage is the
@ -165,7 +165,7 @@ public partial class App : Application
sessionStatus.LoginSucceeded += () => sessionStatus.LoginSucceeded += () =>
{ {
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
_ = PushMainPageAsync(provider, w); _ = PushMainPageAsync();
}; };
// When the user clicks the "Paramètres" button on the // When the user clicks the "Paramètres" button on the
@ -196,7 +196,7 @@ public partial class App : Application
_ = w.NavRoot.PushAsync(settingsPage); _ = w.NavRoot.PushAsync(settingsPage);
}; };
window.Opened += async (_, _) => await BootAsync(provider, api, window); window.Opened += async (_, _) => await BootAsync(provider, api);
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{ {
@ -223,15 +223,14 @@ public partial class App : Application
/// </summary> /// </summary>
private static async Task BootAsync( private static async Task BootAsync(
IServiceProvider provider, IServiceProvider provider,
YavscApiClient api, YavscApiClient api)
MainWindow window)
{ {
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh(); sessionStatus.Refresh();
if (!refreshed) return; if (!refreshed) return;
await PushMainPageAsync(provider, window).ConfigureAwait(true); await PushMainPageAsync().ConfigureAwait(true);
} }
/// <summary> /// <summary>
@ -241,12 +240,13 @@ public partial class App : Application
/// (interactive login from the banner). Pulled out as a helper so /// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart. /// the two callers can't drift apart.
/// </summary> /// </summary>
private static async Task PushMainPageAsync(IServiceProvider provider, MainWindow window) public static async Task PushMainPageAsync()
{ {
var mainVm = provider.GetRequiredService<MainPageViewModel>(); var app = (App)Current;
var mainPage = provider.GetRequiredService<MainPage>(); var mainVm = app.Services.GetRequiredService<MainPageViewModel>();
var mainPage = app.Services.GetRequiredService<MainPage>();
mainPage.DataContext = mainVm; mainPage.DataContext = mainVm;
await window.NavRoot.PushAsync(mainPage).ConfigureAwait(true); await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()

View file

@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.Input;
using PostIt; using PostIt;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; namespace PostIt.ViewModels;
public class HomePageViewModel : ViewModelBase public class HomePageViewModel : ViewModelBase
{ {
@ -22,7 +23,7 @@ public class HomePageViewModel : ViewModelBase
Api = api; Api = api;
Settings = settings; Settings = settings;
} }
public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
/// <summary> /// <summary>
/// Avalonia designer constructor. Builds a self-contained VM /// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can /// with a freshly-constructed Settings so the XAML preview can

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@ -178,10 +179,20 @@ public partial class Settings : ViewModelBase
RedirectUri = Authentication.RedirectUri, RedirectUri = Authentication.RedirectUri,
Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)), Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody, TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
PostLogoutRedirectUri = "https//yavsc.pschneider.fr", PostLogoutRedirectUri = Authentication.Authority,
// PKCE is enabled by default when no client_secret is provided. // PKCE is enabled by default when no client_secret is provided.
}; };
if (IsDevelopmentEnvironment())
{
// Dev only: allow local/self-signed TLS for discovery/token
// endpoints when the machine does not trust a custom root.
options.BackchannelHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
}
if (browser is not null) if (browser is not null)
options.Browser = browser; options.Browser = browser;
@ -231,6 +242,14 @@ public partial class Settings : ViewModelBase
} }
} }
private static bool IsDevelopmentEnvironment()
{
return string.Equals(
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
"Development",
StringComparison.OrdinalIgnoreCase);
}
internal void Load() internal void Load()
{ {
if (Loaded) return; if (Loaded) return;

View file

@ -1,7 +1,12 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.HomePage" x:Class="PostIt.Views.HomePage"
x:DataType="vm:HomePageViewModel"
Header="Home"> Header="Home">
<Design.DataContext>
<vm:HomePageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Center" <StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Spacing="12"> Spacing="12">
@ -9,5 +14,8 @@
FontSize="22" FontSize="22"
FontWeight="SemiBold" FontWeight="SemiBold"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
<Button Content="Open Blog Interface"
Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
</ContentPage> </ContentPage>

View file

@ -27,7 +27,6 @@
<StackPanel Grid.Row="0" Spacing="12" <StackPanel Grid.Row="0" Spacing="12"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Top"> VerticalAlignment="Top">
<TextBlock Text="PostIt Blog API Interface" FontSize="20" FontWeight="Bold" />
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding LoadPosts}" Content="Load posts" /> <Button Command="{Binding LoadPosts}" Content="Load posts" />
@ -93,4 +92,4 @@
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>
</ContentPage> </ContentPage>

View file

@ -20,7 +20,7 @@
--> -->
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<views:SessionStatusBanner x:Name="SessionBanner" <views:SessionStatusBanner x:Name="SessionBanner"
DockPanel.Dock="Top"/> DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/> <NavigationPage x:Name="NavRoot"/>
</DockPanel> </DockPanel>

View file

@ -791,12 +791,12 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
{ {
if (userId == null || code == null) if (userId == null || code == null)
{ {
return View("Error"); return this.ErrorView<AccountController>("Error: userId or code is null.");
} }
var user = await _userManager.FindByIdAsync(userId); var user = await _userManager.FindByIdAsync(userId);
if (user == null) if (user == null)
{ {
return View("Error"); return this.ErrorView<AccountController>("Error: user not found.");
} }
IdentityResult result = null; IdentityResult result = null;
try try
@ -819,12 +819,12 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
{ {
if (userId == null || code == null) if (userId == null || code == null)
{ {
return View("Error"); return this.ErrorView<AccountController>("Error: userId or code is null.");
} }
var user = await _userManager.FindByIdAsync(userId); var user = await _userManager.FindByIdAsync(userId);
if (user == null) if (user == null)
{ {
return View("Error"); return this.ErrorView<AccountController>("Error: user not found.");
} }
bool result = false; bool result = false;
try try
@ -837,7 +837,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
_logger.LogError(ex.StackTrace); _logger.LogError(ex.StackTrace);
_logger.LogError(ex.Message); _logger.LogError(ex.Message);
} }
return View(result ? "EmailConfirmed" : "Error"); return result ? View("EmailConfirmed") : this.ErrorView<AccountController>("Error confirming two factor token.");
} }
// //

View file

@ -1,6 +1,5 @@
using System.Security.Claims; using System.Security.Claims;
using System.IO;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
@ -490,12 +489,7 @@ namespace Yavsc.Controllers
: message == ManageMessageId.Error ? "An error has occurred." : message == ManageMessageId.Error ? "An error has occurred."
: ""; : "";
var user = await GetCurrentUserAsync(); var user = await GetCurrentUserAsync();
if (user == null)
{
return View("Error");
}
var userLogins = await _userManager.GetLoginsAsync(user); var userLogins = await _userManager.GetLoginsAsync(user);
ViewBag.ShowRemoveButton = user.PasswordHash != null || userLogins.Count > 1; ViewBag.ShowRemoveButton = user.PasswordHash != null || userLogins.Count > 1;
return View(new ManageLoginsViewModel return View(new ManageLoginsViewModel
@ -522,15 +516,7 @@ namespace Yavsc.Controllers
public async Task<ActionResult> LinkLoginCallback() public async Task<ActionResult> LinkLoginCallback()
{ {
var user = await GetCurrentUserAsync(); var user = await GetCurrentUserAsync();
if (user == null)
{
return View("Error");
}
var info = await _signInManager.GetExternalLoginInfoAsync(User.GetUserId()); var info = await _signInManager.GetExternalLoginInfoAsync(User.GetUserId());
if (info == null)
{
return RedirectToAction(nameof(ManageLogins), new { Message = ManageMessageId.Error });
}
var result = await _userManager.AddLoginAsync(user, info); var result = await _userManager.AddLoginAsync(user, info);
var message = result.Succeeded ? ManageMessageId.AddLoginSuccess : ManageMessageId.Error; var message = result.Succeeded ? ManageMessageId.AddLoginSuccess : ManageMessageId.Error;
return RedirectToAction(nameof(ManageLogins), new { Message = message }); return RedirectToAction(nameof(ManageLogins), new { Message = message });

View file

@ -16,6 +16,7 @@ using System.Collections.Generic;
using System; using System;
using Yavsc; using Yavsc;
using Yavsc.Extensions; using Yavsc.Extensions;
using Yavsc.Models;
namespace IdentityServerHost.Quickstart.UI namespace IdentityServerHost.Quickstart.UI
{ {
@ -53,10 +54,11 @@ namespace IdentityServerHost.Quickstart.UI
{ {
return View("Index", vm); return View("Index", vm);
} }
return this.ErrorView<ConsentController>("No consent request matching request: " + returnUrl);
return View("Error");
} }
/// <summary> /// <summary>
/// Handles the consent screen postback /// Handles the consent screen postback
/// </summary> /// </summary>
@ -88,8 +90,8 @@ namespace IdentityServerHost.Quickstart.UI
{ {
return View("Index", result.ViewModel); return View("Index", result.ViewModel);
} }
return this.ErrorView<ConsentController>($"ReturnUrl: {model}, result: {result}" );
return View("Error");
} }
/*****************************************/ /*****************************************/
@ -170,11 +172,6 @@ namespace IdentityServerHost.Quickstart.UI
{ {
return CreateConsentViewModel(model, returnUrl, request); return CreateConsentViewModel(model, returnUrl, request);
} }
else
{
_logger.LogError("No consent request matching request: {0}", returnUrl);
}
return null; return null;
} }
@ -199,7 +196,7 @@ namespace IdentityServerHost.Quickstart.UI
vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, vm.ScopesConsented.Contains(x.Name) || model == null)).ToArray(); vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, vm.ScopesConsented.Contains(x.Name) || model == null)).ToArray();
var apiScopes = new List<ScopeViewModel>(); var apiScopes = new List<ScopeViewModel>();
foreach(var parsedScope in request.ValidatedResources.ParsedScopes) foreach (var parsedScope in request.ValidatedResources.ParsedScopes)
{ {
var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName);
if (apiScope != null) if (apiScope != null)

View file

@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Yavsc.Models;
using Yavsc.Models.Access; using Yavsc.Models.Access;
namespace Yavsc.Controllers namespace Yavsc.Controllers
@ -49,7 +50,7 @@ namespace Yavsc.Controllers
if (string.IsNullOrWhiteSpace(userCode)) return View("UserCodeCapture"); if (string.IsNullOrWhiteSpace(userCode)) return View("UserCodeCapture");
var vm = await BuildViewModelAsync(userCode); var vm = await BuildViewModelAsync(userCode);
if (vm == null) return View("Error"); if (vm == null) return this.ErrorView<DeviceController>($"ViewModel is null! userCodeParamName: {userCodeParamName}, userCode: {userCode}" );;
vm.ConfirmUserCode = true; vm.ConfirmUserCode = true;
return View("UserCodeConfirmation", vm); return View("UserCodeConfirmation", vm);
@ -60,7 +61,7 @@ namespace Yavsc.Controllers
public async Task<IActionResult> UserCodeCapture(string userCode) public async Task<IActionResult> UserCodeCapture(string userCode)
{ {
var vm = await BuildViewModelAsync(userCode); var vm = await BuildViewModelAsync(userCode);
if (vm == null) return View("Error"); if (vm == null) return this.ErrorView<DeviceController>($"UserCodeCapture: ViewModel is null! userCode: {userCode}" );
return View("UserCodeConfirmation", vm); return View("UserCodeConfirmation", vm);
} }
@ -72,7 +73,20 @@ namespace Yavsc.Controllers
if (model == null) throw new ArgumentNullException(nameof(model)); if (model == null) throw new ArgumentNullException(nameof(model));
var result = await ProcessConsent(model); var result = await ProcessConsent(model);
if (result.HasValidationError) return View("Error"); if (result.HasValidationError)
{
if (HttpContext.RequestServices.GetRequiredService<IHostEnvironment>().IsDevelopment())
{
throw new InvalidOperationException("Device Authorization Input validation error: " + result.ValidationError);
}
return View("Error",
new ErrorViewModel {
RequestId = HttpContext.TraceIdentifier,
Description = "Device Authorization Input validation error: " + result.ValidationError
}
);
}
return View("Success"); return View("Success");
} }

View file

@ -15,18 +15,24 @@ namespace Yavsc.Controllers
public class HomeController : Controller public class HomeController : Controller
{ {
readonly ApplicationDbContext _dbContext; readonly ApplicationDbContext _dbContext;
readonly ILogger<HomeController> _logger;
private readonly bool _isDevelopment;
readonly IHtmlLocalizer _localizer; readonly IHtmlLocalizer _localizer;
private SiteSettings siteSettings; private SiteSettings siteSettings;
public HomeController(ILogger<HomeController> logger, public HomeController(ILogger<HomeController> logger,
IHtmlLocalizer<HomeController> localizer, IHtmlLocalizer<HomeController> localizer,
ApplicationDbContext context, ApplicationDbContext context,
IOptions<SiteSettings> settingsOptions) IOptions<SiteSettings> settingsOptions,
IWebHostEnvironment env
)
{ {
_localizer = localizer; _localizer = localizer;
_dbContext = context; _dbContext = context;
siteSettings = settingsOptions.Value; siteSettings = settingsOptions.Value;
_logger = logger;
_isDevelopment = env.IsDevelopment();
} }
public async Task<IActionResult> Index(string id) public async Task<IActionResult> Index(string id)
@ -99,18 +105,44 @@ namespace Yavsc.Controllers
public IActionResult Error() public IActionResult Error()
{ {
var feature = this.HttpContext.Features.Get<IExceptionHandlerFeature>(); if (_isDevelopment)
if (feature == null) return View();
var errorType = feature?.Error;
if (errorType == null) return View();
if (errorType is NotSupportedException notSupported)
{ {
return View(new ErrorViewModel { _logger.LogInformation(
Description = notSupported.Message, "Home/Error requested in Development. This endpoint is disabled because DeveloperExceptionPage should handle unhandled exceptions.");
RequestId = this.HttpContext.TraceIdentifier
}); return NotFound(
"In Development, /Home/Error is disabled. Unhandled exceptions are rendered by DeveloperExceptionPage.");
} }
return View("~/Views/Shared/Error.cshtml", feature?.Error);
var errorViewModel = new ErrorViewModel
{
RequestId = HttpContext.TraceIdentifier
};
var exceptionHandlerPathFeature =
HttpContext.Features.Get<IExceptionHandlerPathFeature>();
if (exceptionHandlerPathFeature is null)
{
_logger.LogWarning(
"Home/Error called without IExceptionHandlerPathFeature in non-development environment.");
return View("~/Views/Shared/Error.cshtml", errorViewModel);
}
if (exceptionHandlerPathFeature?.Error is FileNotFoundException)
{
errorViewModel.Description = "The file was not found.";
}
if (exceptionHandlerPathFeature?.Path == "/")
{
errorViewModel.Description ??= string.Empty;
errorViewModel.Description += " Page: Home.";
}
return View("~/Views/Shared/Error.cshtml", errorViewModel);
} }
public IActionResult Status(int id) public IActionResult Status(int id)
{ {

View file

@ -967,12 +967,12 @@ public static class HostingExtensions
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
await app.MigrateDatabaseAsync();
} }
else else
{ {
app.UseExceptionHandler("/Home/Error"); app.UseExceptionHandler("/Home/Error");
logger.LogInformation("Running in production mode. Ensure the database is migrated."); app.UseHsts();
logger.LogInformation("⨝ Running in production mode. Ensure the database is migrated.");
await app.MigrateDatabaseAsync(); await app.MigrateDatabaseAsync();
} }

View file

@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using Yavsc.Models;
public static class ErrorViewHelpers
{
public static IActionResult ErrorView<T>(this Controller controller, string message)
{
var logger = controller.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger<T>();
logger.LogError(message);
Dictionary<string, string> dictionary = new Dictionary<string, string>();
if (!controller.ModelState.IsValid)
{
foreach (var modelState in controller.ModelState.Values)
{
foreach (var error in modelState.Errors)
{
logger.LogError("ModelState error: {0}", error.ErrorMessage);
foreach (var key in controller.ModelState.Keys)
{
logger.LogError("ModelState key: {0}", key);
dictionary.Add(key,
string.Join("\n",
controller.ModelState[key].Errors.Select( e => e.ErrorMessage).ToArray()));
}
}
}
}
if (controller.HttpContext.Request.Headers.ContainsKey("Accept")
&& controller.HttpContext.Request.Headers["Accept"].ToString().Contains("application/json"))
{
return controller.Json(new
{
RequestId = controller.HttpContext.TraceIdentifier,
Description = message,
ModelErrors = dictionary
});
}
return controller.View("Error",
new ErrorViewModel
{
RequestId = controller.HttpContext.TraceIdentifier,
Description = message,
ModelErrors = dictionary
}
);
}
}

View file

@ -1,14 +1,41 @@
@model ErrorViewModel @using Microsoft.AspNetCore.Hosting
@using Yavsc.Models
@inject IWebHostEnvironment Env
@model object
@{ @{
ViewBag.Title = "Error"; ViewBag.Title = "Error";
} }
<h1 class="text-danger">Error.</h1> <h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model!=null) if (Model.ShowRequestId) @if (Env.IsDevelopment())
{ {
<p> <h2 class="text-danger">An unhandled exception occurred while processing your request.</h2>
<strong>Request ID:</strong> <code>@Model.RequestId</code> if (Model is Exception exception)
</p> {
<pre class="text-danger">@exception.ToString()</pre>
}
else if (Model is ErrorViewModel errorViewModel && !string.IsNullOrWhiteSpace(errorViewModel.Description))
{
<pre class="text-danger">@errorViewModel.Description</pre>
}
}
else
{
<h2 class="text-danger">An error occurred while processing your request.</h2>
if (Model is ErrorViewModel errorViewModel)
{
if (errorViewModel.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@errorViewModel.RequestId</code>
</p>
}
if (!string.IsNullOrWhiteSpace(errorViewModel.Description))
{
<p class="text-danger">@errorViewModel.Description</p>
}
}
} }

View file

@ -7,4 +7,5 @@ public class ErrorViewModel
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
public Dictionary<string, string> ModelErrors { get; set; }
} }