From e76259cca20f5cafd00388d9cfa08f6112da9d82 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 25 Jun 2026 20:57:56 +0100 Subject: [PATCH] Richer Client/Details view for admin triage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Details view was a sketch: a handful of fields, a half-broken
/
pairing around FrontChannelLogoutUri, and nothing about token lifetimes, security flags, or collection sizes. For an admin trying to understand what a given OIDC client actually does (and why a login flow fails), that meant bouncing between the list page and the edit page to read off half a dozen scalars. The new view surfaces the same property surface as Edit.cshtml, but read-only: - Two-column layout: Identity + Security on the left, Tokens + Logout on the right. Security flags render as a Bootstrap 3 label (green/grey) so an admin can spot at a glance whether PKCE, consent, offline access, etc. are on or off. - Lifetimes are formatted in human units (5 min, 2 h, 30 d) instead of raw seconds. Zero / unset is rendered as 'default' or '—' to avoid the silent-zero footgun. - Enum-valued columns (AccessTokenType, RefreshTokenUsage, RefreshTokenExpiration) are rendered as their integer value since that's the on-disk representation in IdentityServer8. - The Collections list is mirrored from Edit.cshtml so every nested editor (scopes, grant types, redirect URIs, CORS origins, IdP restrictions, claims, properties, secrets) is one click away. - Secrets get a structured table: type, description, created/expiration timestamps, and a status badge (active / expires soon / expired / no expiry). Secret values are never displayed — only the freshly generated one, via the existing RegenerateSecret flow — and the note is repeated here so the table can't be misread. - Footer promoted from inline links to a button bar (Edit, Regenerate secret, Back to List) for clearer call-to-action. The ClientSecret property surface was confirmed by decompiling IdentityServer8.EntityFramework.Storage 8.0.5: Expiration is DateTime? (null = no expiry), Created is DateTime (default UtcNow). No MinValue sentinel — previous draft's handling was wrong and has been replaced by a single DateOrDash(DateTime?) helper. --- src/Yavsc.Org/Views/Client/Details.cshtml | 351 ++++++++++++++++++---- 1 file changed, 288 insertions(+), 63 deletions(-) diff --git a/src/Yavsc.Org/Views/Client/Details.cshtml b/src/Yavsc.Org/Views/Client/Details.cshtml index 188f4269..b39a3f70 100644 --- a/src/Yavsc.Org/Views/Client/Details.cshtml +++ b/src/Yavsc.Org/Views/Client/Details.cshtml @@ -1,71 +1,296 @@ @model Client +@using Microsoft.AspNetCore.Html +@using System.Text + +@functions { + // Lifetimes in IdentityServer are stored as seconds (int). Render them + // in a human-friendly way: "5 min", "2 h", "30 d", "—" for 0/negative. + // Zero typically means "use the global default" — show it as such so + // an admin isn't misled into thinking the value is unset. + static string HumanLifetime(int? seconds) + { + if (!seconds.HasValue) return "—"; + var s = seconds.Value; + if (s <= 0) return "default"; + if (s < 60) return $"{s} s"; + if (s < 3600) return $"{s / 60} min"; + if (s < 86400) return $"{s / 3600} h"; + if (s < 86400 * 30) return $"{s / 86400} d"; + return $"{s / 86400} d ({s / 86400 / 30} mo)"; + } + + // IdentityServer8 stores enum-valued config columns as plain `int` + // (no enum type metadata at the storage layer). Render the numeric + // value directly. The matching enum name lives on the wire side + // (Discovery / token claims), not in this admin view. + static string IntOrZero(int value) => value.ToString(); + + static IHtmlContent BoolBadge(bool value) => + new HtmlString(value + ? "yes" + : "no"); + + static IHtmlContent EnabledBadge(bool value) => + new HtmlString(value + ? "enabled" + : "disabled"); + + // IdentityServer8: ClientSecret.Expiration is DateTime? (null = no + // expiry). ClientSecret.Created is DateTime, defaulted to UtcNow by + // the framework, so it never carries a MinValue sentinel — we render + // it as-is. + static string DateOrDash(DateTime? value) => + value?.ToString("yyyy-MM-dd HH:mm") ?? "—"; +}

@Localizer["Details"]

-
-

Client

-
-
-
- @Html.DisplayNameFor(model => model.ClientId) -
-
- @Html.DisplayFor(model => model.ClientId) -
-
- @Html.DisplayNameFor(model => model.Enabled) -
-
- @Html.DisplayFor(model => model.Enabled) -
-
- @Html.DisplayNameFor(model => model.ClientName) -
-
- @Html.DisplayFor(model => model.ClientName) -
-
- @Html.DisplayNameFor(model => model.FrontChannelLogoutUri) -
- @Html.DisplayFor(model => model.FrontChannelLogoutUri) -
-
- @Html.DisplayNameFor(model => model.RedirectUris) -
-
- -
-
- @Html.DisplayNameFor(model => model.AbsoluteRefreshTokenLifetime) -
-
- @Html.DisplayFor(model => model.AbsoluteRefreshTokenLifetime) -
-
- @Html.DisplayNameFor(model => model.ClientSecrets) -
-
- -
+

+ @Model.ClientId + @EnabledBadge(Model.Enabled) + @if (!string.IsNullOrWhiteSpace(Model.ClientName)) + { + — @Model.ClientName + } +

+
-
- @Html.DisplayNameFor(model => model.AccessTokenType) -
-
- @Html.DisplayFor(model => model.AccessTokenType) -
- +
+
+
+ Identity +
+
@Html.DisplayNameFor(m => m.ClientId)
+
@Model.ClientId
+ +
@Html.DisplayNameFor(m => m.Enabled)
+
@EnabledBadge(Model.Enabled)
+ +
@Html.DisplayNameFor(m => m.ProtocolType)
+
@(string.IsNullOrEmpty(Model.ProtocolType) ? "—" : Model.ProtocolType)
+ +
@Html.DisplayNameFor(m => m.ClientName)
+
@(Model.ClientName ?? "—")
+ +
@Html.DisplayNameFor(m => m.Description)
+
@(Model.Description ?? "—")
+ +
@Html.DisplayNameFor(m => m.ClientUri)
+
+ @if (!string.IsNullOrWhiteSpace(Model.ClientUri)) + { + @Model.ClientUri + } + else + { + + } +
+ +
@Html.DisplayNameFor(m => m.LogoUri)
+
+ @if (!string.IsNullOrWhiteSpace(Model.LogoUri)) + { + + logo + + } + else + { + + } +
+
+
+ +
+ Security + + + + + + + + + + + + + + + + +
@Html.DisplayNameFor(m => m.RequireConsent)@BoolBadge(Model.RequireConsent)
@Html.DisplayNameFor(m => m.RequirePkce)@BoolBadge(Model.RequirePkce)
@Html.DisplayNameFor(m => m.RequireRequestObject)@BoolBadge(Model.RequireRequestObject)
@Html.DisplayNameFor(m => m.RequireClientSecret)@BoolBadge(Model.RequireClientSecret)
@Html.DisplayNameFor(m => m.AllowPlainTextPkce)@BoolBadge(Model.AllowPlainTextPkce)
@Html.DisplayNameFor(m => m.AllowOfflineAccess)@BoolBadge(Model.AllowOfflineAccess)
@Html.DisplayNameFor(m => m.AllowRememberConsent)@BoolBadge(Model.AllowRememberConsent)
@Html.DisplayNameFor(m => m.EnableLocalLogin)@BoolBadge(Model.EnableLocalLogin)
@Html.DisplayNameFor(m => m.AlwaysIncludeUserClaimsInIdToken)@BoolBadge(Model.AlwaysIncludeUserClaimsInIdToken)
@Html.DisplayNameFor(m => m.AlwaysSendClientClaims)@BoolBadge(Model.AlwaysSendClientClaims)
@Html.DisplayNameFor(m => m.IncludeJwtId)@BoolBadge(Model.IncludeJwtId)
@Html.DisplayNameFor(m => m.UpdateAccessTokenClaimsOnRefresh)@BoolBadge(Model.UpdateAccessTokenClaimsOnRefresh)
@Html.DisplayNameFor(m => m.AllowAccessTokensViaBrowser)@BoolBadge(Model.AllowAccessTokensViaBrowser)
+
+
+ +
+
+ Tokens + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@Html.DisplayNameFor(m => m.AccessTokenType)@IntOrZero(Model.AccessTokenType)
@Html.DisplayNameFor(m => m.IdentityTokenLifetime)@HumanLifetime(Model.IdentityTokenLifetime)
@Html.DisplayNameFor(m => m.AccessTokenLifetime)@HumanLifetime(Model.AccessTokenLifetime)
@Html.DisplayNameFor(m => m.AuthorizationCodeLifetime)@HumanLifetime(Model.AuthorizationCodeLifetime)
@Html.DisplayNameFor(m => m.AbsoluteRefreshTokenLifetime)@HumanLifetime(Model.AbsoluteRefreshTokenLifetime)
@Html.DisplayNameFor(m => m.SlidingRefreshTokenLifetime)@HumanLifetime(Model.SlidingRefreshTokenLifetime)
@Html.DisplayNameFor(m => m.RefreshTokenUsage)@IntOrZero(Model.RefreshTokenUsage)
@Html.DisplayNameFor(m => m.RefreshTokenExpiration)@IntOrZero(Model.RefreshTokenExpiration)
@Html.DisplayNameFor(m => m.ConsentLifetime)@HumanLifetime(Model.ConsentLifetime)
@Html.DisplayNameFor(m => m.UserSsoLifetime)@HumanLifetime(Model.UserSsoLifetime)
@Html.DisplayNameFor(m => m.DeviceCodeLifetime)@HumanLifetime(Model.DeviceCodeLifetime)
@Html.DisplayNameFor(m => m.UserCodeType)@(string.IsNullOrEmpty(Model.UserCodeType) ? "—" : Model.UserCodeType)
@Html.DisplayNameFor(m => m.AllowedIdentityTokenSigningAlgorithms)@(string.IsNullOrEmpty(Model.AllowedIdentityTokenSigningAlgorithms) ? "—" : Model.AllowedIdentityTokenSigningAlgorithms)
@Html.DisplayNameFor(m => m.ClientClaimsPrefix)@(string.IsNullOrEmpty(Model.ClientClaimsPrefix) ? "—" : Model.ClientClaimsPrefix)
@Html.DisplayNameFor(m => m.PairWiseSubjectSalt)@(string.IsNullOrEmpty(Model.PairWiseSubjectSalt) ? "—" : Model.PairWiseSubjectSalt)
+
+ +
+ Logout +
+
@Html.DisplayNameFor(m => m.FrontChannelLogoutUri)
+
@(string.IsNullOrEmpty(Model.FrontChannelLogoutUri) ? "—" : Model.FrontChannelLogoutUri)
+
@Html.DisplayNameFor(m => m.FrontChannelLogoutSessionRequired)
+
@BoolBadge(Model.FrontChannelLogoutSessionRequired)
+
@Html.DisplayNameFor(m => m.BackChannelLogoutUri)
+
@(string.IsNullOrEmpty(Model.BackChannelLogoutUri) ? "—" : Model.BackChannelLogoutUri)
+
@Html.DisplayNameFor(m => m.BackChannelLogoutSessionRequired)
+
@BoolBadge(Model.BackChannelLogoutSessionRequired)
+
+
+
+ +
+ Collections + +
+ +
+ + Client Secrets + (@(Model.ClientSecrets?.Count ?? 0)) + + + @if (Model.ClientSecrets == null || Model.ClientSecrets.Count == 0) + { +

No secrets on file. The client relies entirely on PKCE (no secret in the handshake).

+ } + else + { + + + + + + + + + + + + @foreach (var secret in Model.ClientSecrets) + { + // Expiration is DateTime?: null means "no expiry". + // Created is DateTime, defaulted to UtcNow at insert time. + var hasExpiry = secret.Expiration.HasValue; + var expired = hasExpiry && secret.Expiration.Value < DateTime.UtcNow; + var expiresSoon = hasExpiry && !expired && secret.Expiration.Value < DateTime.UtcNow.AddDays(7); + + + + + + + + } + +
TypeDescriptionCreated (UTC)Expires (UTC)Status
@(secret.Type ?? "SharedSecret")@(secret.Description ?? "—")@DateOrDash(secret.Created)@DateOrDash(secret.Expiration) + @if (!hasExpiry) + { + no expiry + } + else if (expired) + { + expired + } + else if (expiresSoon) + { + expires soon + } + else + { + active + } +
+

+ Secret values are never displayed here. IdentityServer stores them + hashed; only the freshly generated value is shown once on the + Regenerate Secret flow. +

+ } +
+

- @Localizer["Edit"] | - @Localizer["Regenerate secret"] | - @Localizer["Back to List"] + @Localizer["Edit"] + @Localizer["Regenerate secret"] + @Localizer["Back to List"]