yavsc-org: OAuth2 client admin editor overhaul — per-collection pages + missing fields

The OAuth2 client editor at /Client/Edit/{id} previously exposed 8
fields out of ~30 scalars and 10 collections on the IdentityServer8
Client entity. Editing the collections (RedirectUris, Scopes, Grant
Types, Cors Origins, IdP Restrictions, Claims, Properties, Secrets)
was either impossible or jammed into a single broken text input that
bound against an IEnumerable<string> property.

Restructure into per-collection subpages, each with its own
list/add/remove flow:

- RedirectUris       /Client/EditRedirectUris/{id}
- PostLogoutRedirectUris /Client/EditPostLogoutRedirectUris/{id}
- Scopes             /Client/EditScopes/{id}
- GrantTypes         /Client/EditGrantTypes/{id}
- CorsOrigins        /Client/EditCorsOrigins/{id}
- IdPRestrictions    /Client/EditIdPRestrictions/{id}
- Claims             /Client/EditClaims/{id}
- Properties         /Client/EditProperties/{id}
- Secrets            /Client/EditSecrets/{id}

Implementation:

- New partial class ClientController.Collections.cs with one
  GET/Add/Remove trio per collection. Add/Remove dispatch through
  generic helpers that handle the EF row + ClientId check.
- Shared _EditableStringList.cshtml partial consumed by the six
  single-string-field collection pages. Uses reflection to pull
  the value field and the row Id off the entity — avoids six
  nearly-identical table+form copies.
- Claims / Properties / Secrets each have their own view because
  they carry 2+ fields (Type+Value, Key+Value, or
  Type+Value+Description+Expiration).
- Main Edit.cshtml enriched: ClientId/Id hidden, all scalar
  fields split into fieldsets (Core, Security, Logout, Tokens,
  Device flow, Tokens extra), nav links to the 9 subpages with
  current row counts as badges.
- ClientController.Edit(int) GET now loads the client with all
  navigations via LoadClientAsync so the Edit.cshtml nav badges
  render real counts.

Field-correctness notes (verified by disassembling HigginsSoft
IdentityServer8.EntityFramework.Entities.Client 8.0.5-preview-net9):

- The property is PairWiseSubjectSalt, not PairwiseSubjectSalt
  (capital W on 'Wise').
- CibaLifetime and PollingInterval do NOT exist on Client in this
  IdentityServer8 version — those properties were a guess. The
  Device flow fieldset contains DeviceCodeLifetime + UserCodeType
  instead.
- AllowedIdentityTokenSigningAlgorithms and AllowAccessTokensViaBrowser
  were missing from the original form and are now exposed.
- ConsentLifetime and UserSsoLifetime are int? (nullable); the form
  binds them as plain int fields which accept empty strings.

Security:

- All new actions stay under [Authorize('AdministratorOnly')].
- Each Add/Remove takes an explicit id (Client.Id) and the row's
  ClientId is checked on the server before any delete; a rowId
  from another client returns NotFound.

Docs:

- doc/dev-tracking/client-editor-overhaul.md — inventory, status,
  follow-up ideas (confirmation prompts, validation, MVC tests).
This commit is contained in:
Paul Schneider 2026-06-21 16:53:34 +01:00
commit ecac359344
14 changed files with 1255 additions and 31 deletions

View file

@ -0,0 +1,347 @@
using IdentityServer8.EntityFramework.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.EntityFrameworkCore;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers;
/// <summary>
/// Partial class that adds the per-collection edit pages for an OAuth2
/// client. See <c>ClientController.cs</c> for the scalar edit flow and
/// the seed/secret management. The collection pages are deliberately
/// factored into a separate file so the controller stays navigable.
///
/// Each collection has three actions:
/// <list type="bullet">
/// <item><c>GET Edit{Collection}(int id)</c> — render the page</item>
/// <item><c>POST Add{Collection}(int id, …)</c> — append a row</item>
/// <item><c>POST Remove{Collection}(int id, int rowId)</c> — delete a row</item>
/// </list>
/// </summary>
[Authorize("AdministratorOnly")]
public partial class ClientController
{
// ---- Redirect URIs ------------------------------------------------
readonly IHtmlLocalizer _localizer;
public ClientController(
IHtmlLocalizer<ClientController> localizer
)
{
_localizer = localizer;
}
[HttpGet]
public async Task<IActionResult> EditRedirectUris(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.RedirectUris.ToList());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddRedirectUri(int id, string redirectUri)
{
return await AddCollectionRowAsync<ClientRedirectUri>(
id, redirectUri,
(client, uri) => new ClientRedirectUri { ClientId = client.Id, RedirectUri = uri });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveRedirectUri(int id, int rowId)
=> await RemoveCollectionRowAsync<ClientRedirectUri>(id, rowId, "EditRedirectUris");
// ---- Post-logout Redirect URIs -----------------------------------
[HttpGet]
public async Task<IActionResult> EditPostLogoutRedirectUris(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.PostLogoutRedirectUris.ToList());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddPostLogoutRedirectUri(int id, string postLogoutRedirectUri)
{
return await AddCollectionRowAsync<ClientPostLogoutRedirectUri>(
id, postLogoutRedirectUri,
(client, uri) => new ClientPostLogoutRedirectUri { ClientId = client.Id, PostLogoutRedirectUri = uri });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemovePostLogoutRedirectUri(int id, int rowId)
=> await RemoveCollectionRowAsync<ClientPostLogoutRedirectUri>(id, rowId, "EditPostLogoutRedirectUris");
// ---- Allowed Scopes ----------------------------------------------
[HttpGet]
public async Task<IActionResult> EditScopes(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.AllowedScopes.ToList());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddScope(int id, string scope)
{
return await AddCollectionRowAsync<ClientScope>(
id, scope,
(client, s) => new ClientScope { ClientId = client.Id, Scope = s });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveScope(int id, int rowId)
=> await RemoveCollectionRowAsync<ClientScope>(id, rowId, "EditScopes");
// ---- Allowed Grant Types -----------------------------------------
[HttpGet]
public async Task<IActionResult> EditGrantTypes(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.AllowedGrantTypes.ToList());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddGrantType(int id, string grantType)
{
return await AddCollectionRowAsync<ClientGrantType>(
id, grantType,
(client, g) => new ClientGrantType { ClientId = client.Id, GrantType = g });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveGrantType(int id, int rowId)
=> await RemoveCollectionRowAsync<ClientGrantType>(id, rowId, "EditGrantTypes");
// ---- Allowed CORS Origins ----------------------------------------
[HttpGet]
public async Task<IActionResult> EditCorsOrigins(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.AllowedCorsOrigins.ToList());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddCorsOrigin(int id, string origin)
{
return await AddCollectionRowAsync<ClientCorsOrigin>(
id, origin,
(client, o) => new ClientCorsOrigin { ClientId = client.Id, Origin = o });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveCorsOrigin(int id, int rowId)
=> await RemoveCollectionRowAsync<ClientCorsOrigin>(id, rowId, "EditCorsOrigins");
// ---- IdentityProvider Restrictions -------------------------------
[HttpGet]
public async Task<IActionResult> EditIdPRestrictions(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.IdentityProviderRestrictions.ToList());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddIdPRestriction(int id, string provider)
{
return await AddCollectionRowAsync<ClientIdPRestriction>(
id, provider,
(client, p) => new ClientIdPRestriction { ClientId = client.Id, Provider = p });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveIdPRestriction(int id, int rowId)
=> await RemoveCollectionRowAsync<ClientIdPRestriction>(id, rowId, "EditIdPRestrictions");
// ---- Claims ------------------------------------------------------
[HttpGet]
public async Task<IActionResult> EditClaims(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.Claims.ToList());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddClaim(int id, string type, string value)
{
if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(value))
{
TempData["Error"] = _localizer["BothTypeAndValueRequired"].Value;
return RedirectToAction("EditClaims", new { id });
}
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
dbContext.Set<ClientClaim>().Add(new ClientClaim { ClientId = client.Id, Type = type, Value = value });
await dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction("EditClaims", new { id });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveClaim(int id, int rowId)
{
var row = await dbContext.Set<ClientClaim>().FindAsync(rowId);
if (row is null || row.ClientId != id) return NotFound();
dbContext.Set<ClientClaim>().Remove(row);
await dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction("EditClaims", new { id });
}
// ---- Properties (key/value) --------------------------------------
[HttpGet]
public async Task<IActionResult> EditProperties(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.Properties.ToList());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddProperty(int id, string key, string value)
{
if (string.IsNullOrWhiteSpace(key))
{
TempData["Error"] = _localizer["KeyRequired"].Value;
return RedirectToAction("EditProperties", new { id });
}
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
dbContext.Set<ClientProperty>().Add(new ClientProperty { ClientId = client.Id, Key = key, Value = value });
await dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction("EditProperties", new { id });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveProperty(int id, int rowId)
{
var row = await dbContext.Set<ClientProperty>().FindAsync(rowId);
if (row is null || row.ClientId != id) return NotFound();
dbContext.Set<ClientProperty>().Remove(row);
await dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction("EditProperties", new { id });
}
// ---- Secrets -----------------------------------------------------
[HttpGet]
public async Task<IActionResult> EditSecrets(int id)
{
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
SetAppTypesInputValues();
return View(client.ClientSecrets?.ToList() ?? new List<ClientSecret>());
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddSecret(int id, string value, string description, DateTime? expiration)
{
if (string.IsNullOrWhiteSpace(value))
{
TempData["Error"] = _localizer["SecretValueRequired"].Value;
return RedirectToAction("EditSecrets", new { id });
}
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
dbContext.ClientSecrets.Add(new ClientSecret
{
ClientId = client.Id,
Type = "SharedSecret",
Value = value,
Description = description,
Created = DateTime.UtcNow,
Expiration = expiration
});
await dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction("EditSecrets", new { id });
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveSecret(int id, int rowId)
{
var row = await dbContext.ClientSecrets.FindAsync(rowId);
if (row is null || row.ClientId != id) return NotFound();
dbContext.ClientSecrets.Remove(row);
await dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction("EditSecrets", new { id });
}
// ---- Helpers -----------------------------------------------------
private async Task<Client?> LoadClientAsync(int id)
=> await dbContext.Clients
.Include(c => c.RedirectUris)
.Include(c => c.PostLogoutRedirectUris)
.Include(c => c.AllowedScopes)
.Include(c => c.AllowedGrantTypes)
.Include(c => c.AllowedCorsOrigins)
.Include(c => c.IdentityProviderRestrictions)
.Include(c => c.Claims)
.Include(c => c.Properties)
.Include(c => c.ClientSecrets)
.SingleOrDefaultAsync(c => c.Id == id);
private async Task<IActionResult> AddCollectionRowAsync<TEntity>(
int id,
string value,
Func<Client, string, TEntity> factory)
where TEntity : class
{
if (string.IsNullOrWhiteSpace(value))
{
TempData["Error"] = _localizer["ValueRequired"].Value;
return RedirectToAction(RedirectTargetFor<TEntity>(), new { id });
}
var client = await LoadClientAsync(id);
if (client is null) return NotFound();
dbContext.Add(factory(client, value));
await dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction(RedirectTargetFor<TEntity>(), new { id });
}
private async Task<IActionResult> RemoveCollectionRowAsync<TEntity>(int id, int rowId, string redirectAction)
where TEntity : class
{
var row = await dbContext.FindAsync<TEntity>(rowId);
if (row is null) return NotFound();
// IdentityServer8 navigation properties are not always populated
// by FindAsync; rely on the FK check on the caller side.
var fk = (row as dynamic).ClientId as int?;
if (fk is null || fk != id) return NotFound();
dbContext.Remove(row);
await dbContext.SaveChangesAsync(User.GetUserId());
return RedirectToAction(redirectAction, new { id });
}
private static string RedirectTargetFor<TEntity>() => typeof(TEntity).Name switch
{
nameof(ClientRedirectUri) => nameof(EditRedirectUris),
nameof(ClientPostLogoutRedirectUri) => nameof(EditPostLogoutRedirectUris),
nameof(ClientScope) => nameof(EditScopes),
nameof(ClientGrantType) => nameof(EditGrantTypes),
nameof(ClientCorsOrigin) => nameof(EditCorsOrigins),
nameof(ClientIdPRestriction) => nameof(EditIdPRestrictions),
_ => "Edit",
};
}

View file

@ -13,7 +13,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Authorize("AdministratorOnly")]
public class ClientController : Controller
public partial class ClientController : Controller
{
private readonly ApplicationDbContext dbContext;
private readonly ClientStore clientStore;
@ -137,8 +137,8 @@ namespace Yavsc.Controllers
// GET: Client/Edit/5
public async Task<IActionResult> Edit(int id)
{
Client client = await dbContext.Clients.SingleOrDefaultAsync(m => m.Id == id);
if (client == null)
Client? client = await LoadClientAsync(id);
if (client is null)
{
return NotFound();
}