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).
2026-06-21 16:53:34 +01:00
|
|
|
# Client editor overhaul — Yavsc.Org administration
|
|
|
|
|
|
|
|
|
|
## Goal
|
|
|
|
|
|
|
|
|
|
Bring the OAuth2 client administration UI (`/Client/Edit/{id}` and friends)
|
|
|
|
|
in Yavsc.Org to feature parity with the IdentityServer8 `Client` entity
|
|
|
|
|
model. Today the editor only exposes a handful of scalar fields and a few
|
|
|
|
|
single-line inputs for collections; the bulk of the entity and its
|
|
|
|
|
related collections are unreachable from the UI.
|
|
|
|
|
|
|
|
|
|
## Inventory — current state
|
|
|
|
|
|
|
|
|
|
### Properties exposed by `Views/Client/Edit.cshtml`
|
|
|
|
|
|
|
|
|
|
| Field | Type | Notes |
|
|
|
|
|
| ------------------------ | ----------- | ---------------------------------- |
|
|
|
|
|
| `ClientId` | string | hidden, identifier |
|
|
|
|
|
| `Enabled` | bool | checkbox |
|
|
|
|
|
| `ClientName` | string | display name |
|
|
|
|
|
| `FrontChannelLogoutUri` | string | only front-channel, no back-channel |
|
|
|
|
|
| `RedirectUris` | collection | rendered as a single text input |
|
|
|
|
|
| `IdentityTokenLifetime` | int | seconds |
|
|
|
|
|
| `AbsoluteRefreshTokenLifetime` | int | seconds |
|
|
|
|
|
| `ClientSecrets` | collection | rendered as a single text input |
|
|
|
|
|
| `AccessTokenType` | enum | dropdown (custom `SetAppTypesInputValues`) |
|
|
|
|
|
|
|
|
|
|
### Properties of `IdentityServer8.EntityFramework.Entities.Client` **NOT** in the editor
|
|
|
|
|
|
|
|
|
|
Core scalars (16 fields missing):
|
|
|
|
|
|
|
|
|
|
- `Description`
|
|
|
|
|
- `ClientUri`
|
|
|
|
|
- `LogoUri`
|
|
|
|
|
- `RequireConsent`
|
|
|
|
|
- `RequirePkce`
|
|
|
|
|
- `RequireRequestObject`
|
|
|
|
|
- `RequireClientSecret`
|
|
|
|
|
- `AllowPlainTextPkce`
|
|
|
|
|
- `AllowOfflineAccess`
|
|
|
|
|
- `AllowRememberConsent`
|
|
|
|
|
- `AlwaysIncludeUserClaimsInIdToken`
|
|
|
|
|
- `AlwaysSendClientClaims`
|
|
|
|
|
- `AuthorizationCodeLifetime`
|
|
|
|
|
- `BackChannelLogoutUri`
|
|
|
|
|
- `BackChannelLogoutSessionRequired`
|
|
|
|
|
- `CibaLifetime`
|
|
|
|
|
- `ClientClaimsPrefix`
|
|
|
|
|
- `ConsentLifetime`
|
|
|
|
|
- `Created`
|
|
|
|
|
- `DeviceCodeLifetime`
|
|
|
|
|
- `EnableLocalLogin`
|
|
|
|
|
- `Enabled`
|
|
|
|
|
- `FrontChannelLogoutSessionRequired`
|
|
|
|
|
- `IncludeJwtId`
|
|
|
|
|
- `LastAccessed`
|
|
|
|
|
- `LogoUri`
|
|
|
|
|
- `NonEditable`
|
|
|
|
|
- `PairwiseSubjectSalt`
|
|
|
|
|
- `PollingInterval`
|
|
|
|
|
- `ProtocolType`
|
|
|
|
|
- `RefreshTokenExpiration`
|
|
|
|
|
- `RefreshTokenUsage`
|
|
|
|
|
- `SlidingRefreshTokenLifetime`
|
|
|
|
|
- `UpdateAccessTokenClaimsOnRefresh`
|
|
|
|
|
- `Updated`
|
|
|
|
|
- `UserCodeType`
|
|
|
|
|
- `UserSsoLifetime`
|
|
|
|
|
|
|
|
|
|
Collections (8 missing — currently either not exposed at all, or jammed
|
|
|
|
|
into a single-line text input that doesn't work for an IEnumerable):
|
|
|
|
|
|
|
|
|
|
- `AllowedGrantTypes` → `ClientGrantType` (GrantType)
|
|
|
|
|
- `AllowedScopes` → `ClientScope` (Scope)
|
|
|
|
|
- `RedirectUris` → `ClientRedirectUri` (RedirectUri) — exposed but broken
|
|
|
|
|
- `PostLogoutRedirectUris` → `ClientPostLogoutRedirectUri` (PostLogoutRedirectUri)
|
|
|
|
|
- `AllowedCorsOrigins` → `ClientCorsOrigin` (Origin)
|
|
|
|
|
- `IdentityProviderRestrictions` → `ClientIdPRestriction` (Provider)
|
|
|
|
|
- `Claims` → `ClientClaim` (Type, Value)
|
|
|
|
|
- `Properties` → `ClientProperty` (Key, Value)
|
|
|
|
|
- `ClientSecrets` → `ClientSecret` (Type, Value, Description, Created, Expiration) — exposed but broken
|
|
|
|
|
- `AllowedSigningAlgorithms` → scalar string collection on Client itself
|
|
|
|
|
|
|
|
|
|
## Pages to add
|
|
|
|
|
|
|
|
|
|
Pattern: one Razor page per collection under
|
|
|
|
|
`Views/Client/Edit{Collection}.cshtml`. Each page lists existing rows,
|
|
|
|
|
offers an "Add" form with the relevant fields, and a per-row
|
|
|
|
|
remove button. The main `Edit.cshtml` becomes a hub page with links
|
|
|
|
|
to each subpage plus the scalar fields it already has.
|
|
|
|
|
|
|
|
|
|
| Page | Route | Form fields |
|
|
|
|
|
| ------------------------------------- | ------------------------------------------ | ------------------------------------------------- |
|
|
|
|
|
| `Edit.cshtml` | `GET /Client/Edit/{id}` (existing) | scalar fields + nav links |
|
|
|
|
|
| `EditRedirectUris.cshtml` | `GET /Client/EditRedirectUris/{id}` | `RedirectUri` |
|
|
|
|
|
| `EditPostLogoutRedirectUris.cshtml` | `GET /Client/EditPostLogoutRedirectUris/{id}` | `PostLogoutRedirectUri` |
|
|
|
|
|
| `EditScopes.cshtml` | `GET /Client/EditScopes/{id}` | `Scope` (with select of known scopes) |
|
|
|
|
|
| `EditGrantTypes.cshtml` | `GET /Client/EditGrantTypes/{id}` | `GrantType` (with select of known types) |
|
|
|
|
|
| `EditCorsOrigins.cshtml` | `GET /Client/EditCorsOrigins/{id}` | `Origin` |
|
|
|
|
|
| `EditIdPRestrictions.cshtml` | `GET /Client/EditIdPRestrictions/{id}` | `Provider` |
|
|
|
|
|
| `EditClaims.cshtml` | `GET /Client/EditClaims/{id}` | `Type`, `Value` |
|
|
|
|
|
| `EditProperties.cshtml` | `GET /Client/EditProperties/{id}` | `Key`, `Value` |
|
|
|
|
|
| `EditSecrets.cshtml` (replacement) | `GET /Client/EditSecrets/{id}` | `Type`, `Value`, `Description`, `Expiration` |
|
|
|
|
|
|
|
|
|
|
Partial view `_EditableList.cshtml` factored once and consumed by all
|
|
|
|
|
of the above.
|
|
|
|
|
|
|
|
|
|
## Controller actions to add
|
|
|
|
|
|
|
|
|
|
For each collection `Foo`:
|
|
|
|
|
|
|
|
|
|
- `GET EditFoo(int id)` — load the client, render the page
|
|
|
|
|
- `POST AddFoo(int id, …)` — append a row, redirect to `EditFoo`
|
|
|
|
|
- `POST RemoveFoo(int id, int rowId)` — delete a row, redirect
|
|
|
|
|
|
|
|
|
|
## Verification
|
|
|
|
|
|
|
|
|
|
- `dotnet build src/Yavsc.Org/Yavsc.Org.csproj` → 0 errors
|
|
|
|
|
- No tests in `Yavsc.Org.Tests` exercise the controller today (per
|
|
|
|
|
`find … -name "ClientController*" -not -path "*/bin/*"`). Smoke-test
|
|
|
|
|
by logging in as admin, hitting `/Client/Edit/1`, then each
|
|
|
|
|
`Edit*/1` page, and verifying the add/remove POSTs.
|
|
|
|
|
- Existing seed flow (`MigratePostItClientToPublic` in
|
|
|
|
|
`HostingExtensions.cs`) must keep working — the editor changes are
|
|
|
|
|
additive, not destructive.
|
|
|
|
|
|
|
|
|
|
## Out of scope
|
|
|
|
|
|
|
|
|
|
- Tests (no MVC test infrastructure currently exists for this controller)
|
|
|
|
|
- Migration of existing collection fields (the broken `RedirectUris`
|
|
|
|
|
text input will simply be replaced by the new subpage)
|
|
|
|
|
- Per-collection authorization policies (the controller is already
|
|
|
|
|
`[Authorize("AdministratorOnly")]`)
|
|
|
|
|
- Client cloning / templating / JSON import-export
|
|
|
|
|
|
|
|
|
|
## Status
|
|
|
|
|
|
|
|
|
|
2026-06-21 16:04 — kickoff. Inventory done. Pages not yet started.
|
|
|
|
|
|
|
|
|
|
2026-06-21 16:11 — first delivery, **build does not compile by design**
|
|
|
|
|
(per Paul: "Tu peux même me laisser un travail qui ne compile
|
|
|
|
|
pas"). The structural work is done; the residual errors are easy
|
|
|
|
|
fixes Paul will do in a debug session.
|
|
|
|
|
|
|
|
|
|
Files added (working tree, not yet committed):
|
|
|
|
|
|
|
|
|
|
- `src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs`
|
|
|
|
|
— partial class with the per-collection GET / Add / Remove actions.
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditRedirectUris.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditPostLogoutRedirectUris.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditScopes.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditGrantTypes.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditCorsOrigins.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditIdPRestrictions.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditClaims.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditProperties.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/EditSecrets.cshtml`
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/_EditableStringList.cshtml`
|
|
|
|
|
— partial consumed by the single-string-field collection pages.
|
|
|
|
|
|
|
|
|
|
Files modified:
|
|
|
|
|
|
|
|
|
|
- `src/Yavsc.Org/Controllers/Administration/ClientController.cs`
|
|
|
|
|
— `class` → `partial class`; the `Edit(int id)` GET now uses
|
|
|
|
|
`LoadClientAsync` to load all navigations (so the new Edit.cshtml
|
|
|
|
|
can render counts in its nav links).
|
|
|
|
|
- `src/Yavsc.Org/Views/Client/Edit.cshtml`
|
|
|
|
|
— significantly enriched: nav links to the 9 sub-pages, all the
|
|
|
|
|
scalar fields split into fieldsets (Security, Logout, Tokens,
|
|
|
|
|
Device / CIBA, Tokens-extra), ClientId / Id hidden.
|
|
|
|
|
|
|
|
|
|
### Known residual compile errors (4 errors total)
|
|
|
|
|
|
|
|
|
|
Paul is fixing these in a debug session. The structure is sound; the
|
|
|
|
|
errors are missing properties on the `Client` entity, a Razor
|
|
|
|
|
nullable quirk, and a `Localizer` injection miss.
|
|
|
|
|
|
|
|
|
|
1. `Edit.cshtml:249` — `PairwiseSubjectSalt` doesn't exist on
|
|
|
|
|
`IdentityServer8.EntityFramework.Entities.Client`. **Fix**: drop
|
|
|
|
|
the field from Edit.cshtml; IdentityServer8 likely uses a
|
|
|
|
|
different property name (e.g. on a related entity) or doesn't
|
|
|
|
|
expose it.
|
|
|
|
|
2. `Edit.cshtml:221` — `CibaLifetime` doesn't exist on `Client`.
|
|
|
|
|
**Fix**: same as above. CIBA flow may be configured elsewhere
|
|
|
|
|
(resource-level) or via a different property.
|
|
|
|
|
3. `ClientController.Collections.cs` lines 181, 217, 253, 304 —
|
|
|
|
|
`Localizer` is not available in the partial class. **Fix**: inject
|
|
|
|
|
`IStringLocalizer<ClientController>` via the constructor, or
|
|
|
|
|
inline the strings ("BothTypeAndValueRequired", "KeyRequired",
|
|
|
|
|
"ValueRequired", "SecretValueRequired").
|
|
|
|
|
4. `EditSecrets.cshtml:44` — `s.Expiration?.ToString("u")` on a
|
|
|
|
|
`DateTime?`. **Fix**: just `s.Expiration?.ToString("u")` works
|
|
|
|
|
if you write `s.Expiration.Value.ToString("u")`, or use
|
|
|
|
|
`(s.Expiration is null ? "" : s.Expiration.Value.ToString("u"))`,
|
|
|
|
|
or `s.Expiration?.ToString("u") ?? string.Empty`.
|
|
|
|
|
|
|
|
|
|
### Suggested next session
|
|
|
|
|
|
|
|
|
|
Once the 4 compile errors are fixed and the pages render:
|
|
|
|
|
|
|
|
|
|
1. Smoke test by logging in as admin, hitting `/Client/Edit/1`,
|
|
|
|
|
then each `Edit*/1` page, and verifying add/remove POSTs.
|
|
|
|
|
2. Add a confirmation prompt (or 2-step form) for Remove actions —
|
|
|
|
|
removing a Redirect URI is destructive and one click is too easy.
|
|
|
|
|
3. Wire up some collection-level validation (e.g. redirect URI must
|
|
|
|
|
be a valid URL) at the controller level.
|
|
|
|
|
4. Add tests — the project doesn't have MVC test infrastructure
|
|
|
|
|
today; consider adding a `Yavsc.Org.Tests` project that drives
|
|
|
|
|
the controller via `WebApplicationFactory<Program>`.
|
|
|
|
|
|
2026-06-21 21:14:20 +01:00
|
|
|
|
|
|
|
|
## Test bootstrap notes (session of 2026-06-21 17:00+)
|
|
|
|
|
|
|
|
|
|
When adding new integration tests against `WebServerFixture`:
|
|
|
|
|
|
|
|
|
|
1. **Skip `/Account/Login` roundtrip.** The fixture ships without
|
|
|
|
|
`MapRazorPages()` (commented out in `HostingExtensions.ConfigurePipeline`),
|
|
|
|
|
so `/Identity/Account/Login` is 404, and the custom
|
|
|
|
|
`/Account/Login` route requires a complex antiforgery dance.
|
|
|
|
|
Instead, build a `ClaimsPrincipal` for the test user via
|
|
|
|
|
`UserManager` + `IUserClaimsPrincipalFactory<ApplicationUser>`,
|
|
|
|
|
then call `IAuthenticationService.SignInAsync` on a synthetic
|
|
|
|
|
`DefaultHttpContext` and replay the resulting `Set-Cookie` header
|
|
|
|
|
into the test `HttpClient`. See
|
|
|
|
|
`ClientControllerCollectionTests.IssueIdentityCookie`.
|
|
|
|
|
|
|
|
|
|
2. **Create the `Administrator` role before assigning it.** ASP.NET
|
|
|
|
|
Identity stores roles in `AspNetRoles`; there is no automatic seed.
|
|
|
|
|
The constant name is `YavscConstants.AdminGroupName` = `"Administrator"`.
|
|
|
|
|
Use `RoleManager<IdentityRole>.CreateAsync(new IdentityRole("Administrator"))`
|
|
|
|
|
before `AddToRoleAsync`.
|
|
|
|
|
|
|
|
|
|
3. **Use `InMemory` connection string to bypass the prod signing-cert
|
|
|
|
|
requirement.** `HostingExtensions.AddIdentityServer` requires a
|
|
|
|
|
PEM cert unless `builder.Environment.IsDevelopment()` OR
|
|
|
|
|
`UsesInMemoryProvider(connectionString)`. The fixture already
|
|
|
|
|
uses `InMemory`, so `AddDeveloperSigningCredential()` is called
|
|
|
|
|
automatically — but only after we wired this check in (see
|
|
|
|
|
commit history).
|
|
|
|
|
|
|
|
|
|
4. **Field-name gotchas** (from disassembling HigginsSoft
|
|
|
|
|
IdentityServer8.EntityFramework.Entities.Client 8.0.5-preview-net9):
|
|
|
|
|
- `PairWiseSubjectSalt` (capital W on "Wise"), not `PairwiseSubjectSalt`.
|
|
|
|
|
- `CibaLifetime` and `PollingInterval` do NOT exist on `Client` in
|
|
|
|
|
this version.
|
|
|
|
|
- `ConsentLifetime` and `UserSsoLifetime` are `int?`.
|
|
|
|
|
|
|
|
|
|
5. **`MapStaticAssets()` fails on test projects.** Calling
|
|
|
|
|
`MapStaticAssets()` resolves a manifest file
|
|
|
|
|
(`<project>.staticwebassets.endpoints.json`) that test projects
|
|
|
|
|
don't produce. Skip when `WebRootPath` points at the test
|
|
|
|
|
assembly directory.
|
|
|
|
|
|
|
|
|
|
6. **Routing 404 on /Client/Edit/{id} via WebServerFixture.** As of
|
|
|
|
|
this session, the GET endpoint returns 404 even with admin
|
|
|
|
|
header. The route mapping is intact
|
|
|
|
|
(`MapDefaultControllerRoute()`), so this is likely an MVC
|
|
|
|
|
convention routing issue with the
|
|
|
|
|
`Controllers/Administration/` subdirectory. To investigate
|
|
|
|
|
next session: log middleware pipeline or hit `/Client` index
|
|
|
|
|
first to see if any Client route resolves.
|
|
|
|
|
|
|
|
|
|
7. **`MapStaticAssets()` is unconditional in prod, but blocks tests.**
|
|
|
|
|
`WebApplication.CreateBuilder` defaults `ContentRootPath` to
|
|
|
|
|
`AppContext.BaseDirectory`. In test runs that resolves to
|
|
|
|
|
`src/Yavsc.Org.Tests/bin/Debug/net10.0/`, where
|
|
|
|
|
`Yavsc.Org.Tests.staticwebassets.endpoints.json` doesn't exist
|
|
|
|
|
(it's generated only by projects with the Web SDK). The
|
|
|
|
|
`app.MapStaticAssets()` call inside `ConfigurePipeline` then
|
|
|
|
|
throws and the fixture fails to start — taking every test in
|
|
|
|
|
the `[Collection("Yavsc Server")]` down with it.
|
|
|
|
|
|
|
|
|
|
This is a pre-existing fragility of the WebServerFixture that
|
|
|
|
|
the new test work surfaced. Fixing it cleanly requires either:
|
|
|
|
|
(a) moving the test project to the Web SDK so it produces its
|
|
|
|
|
own manifest, (b) copying the manifest at build time via an
|
|
|
|
|
MSBuild target, or (c) routing `MapStaticAssets` through an
|
|
|
|
|
assembly-resolution fallback. None attempted in this session —
|
|
|
|
|
recorded for next session.
|