You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Serve the management console through the BFF instead of as a browser public client #158
The console runs authorization code + PKCE in JavaScript through @ncode/auth, keeps the access and refresh tokens in the Redux store, and @ncode/store attaches Authorization: Bearer to every management API call — so the family's most privileged surface holds tokens any injected script can take. Once #123 lands, register the console as a confidential BFF client of the root tenant, serve it through the BFF so it calls the management API with the session cookie and the anti-forgery header, and retire the console's in-browser token custody (the Playground stays a public client on purpose — showing tokens is its job). Decide whether the BFF runs in the OP host process or a separate host (docs/backend-for-frontend.md §11). Planning found that the console cannot keep its standalone Vite dev server: proxying /bff and the API through it hides the browser's origin from the BFF unless the host trusts loopback as a reverse proxy, and the forwarded http scheme then trips the host's HTTPS redirection. Serving the console from the OP host under /mgmt/, as apps/auth is served under /account/ (ADR-0065), keeps the console, /bff/*, and the forwarded API on one origin. Touch points: apps/mgmt/src/auth.ts, apps/mgmt/src/AuthGate.tsx, packages/store/src/baseQuery.ts, packages/auth/, src/NCode.Identity.OpenId.Host/Program.cs.
Plan snapshot. The working copy is PLAN.md in the plan's checkout; this comment is not updated as steps run. The plan is longer than one comment allows, so it is split in two: this comment holds steps 1–24, the next one steps 25–48 and the review checklist.
Plan: feat(mgmt): serve the management console through its own BFF host (48 steps) — part 1 of 2
Plan: feat(mgmt): serve the management console through its own BFF host
The management console (src/frontend/apps/mgmt) becomes a BFF frontend: NCode.Identity.ManagementConsole.Host, a
host of its own composed by the new NCode.Identity.ManagementConsole package over AddIdentityBff, serves the built
console bundle, its /bff/* endpoints and the forwarded management API on one origin, as the confidential client mgmt-console of the control-plane tenant (private_key_jwt, PAR, DPoP, back-channel logout). The browser holds no
token: the console's @ncode/auth custody, the Redux token store and the Authorization: Bearer header disappear, and
the console reads its session from /bff/user. The Playground stays a public client.
Context for the executor
Setup (once, in this worktree): dotnet tool restore, then Push-Location src/frontend; npx -y pnpm@10.34.7 install --frozen-lockfile; Pop-Location.
Prelude: none.
Build: $env:MSBUILDDISABLENODEREUSE = '1'; dotnet build <csproj> -m:2. Run one build or test at a time.
Frontend check of one workspace <W> (typecheck, lint, tests with the coverage floors): Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=<W>; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter <W> exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }
Format: after editing a .cs file run dotnet csharpier format <file>; after editing a frontend file run npx -y pnpm@10.34.7 exec prettier --write <file> from src/frontend.
Abbreviations: MC = src/NCode.Identity.ManagementConsole/, MCH = src/NCode.Identity.ManagementConsole.Host/, MCT = tests/NCode.Identity.ManagementConsole.Tests/, IT = tests/NCode.Identity.OpenId.Integration.Tests/, FE = src/frontend/, MG = src/frontend/apps/mgmt/.
Text rule (binding): every comment, XML doc, test name, doc and changelog line describes the product as it is. Never
write "now", "no longer", "previously", "replaces", "renamed", "moved", "instead of the old" or a removed name.
Frontend conventions: src/frontend/README.md (stateless components in @ncode/ui, tests treat console warnings as
errors, coverage floors in each workspace's Vite/Vitest config). jsdom does not implement HTMLFormElement.submit:
a test that submits a form spies on it with vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {}).
Gotchas:
The OP's outbound guard (DefaultOutboundHttpForgeryGuard) never reaches loopback or private addresses, so on a
developer machine the OP cannot fetch a jwks_uri or deliver a back-channel logout to https://localhost:5005.
That is why the developer seed registers the console's public key by value.
MapBffRemoteApi forwards through its own SocketsHttpHandler, which a TestServer cannot receive: integration
tests prove the BFF guard on /management/api/* (401/403) and never a forwarded call.
WebApplicationFactory hosts run in Development without their launch profile (see IT/Infrastructure/HostApplicationFactory.cs).
The BFF frontend of the console is named mgmt, its client id is mgmt-console, its key rings are mgmt-client
(private_key_jwt) and mgmt-dpop (DPoP), the console host listens on https://localhost:5005 and http://localhost:5004.
Decisions
D1 — Separate host (ADR-1437). The console's BFF runs in its own process and origin, NCode.Identity.ManagementConsole.Host,
not in the OP host under /mgmt/ as the issue sketched. The most privileged UI never shares an origin with the OP's
login, consent and account pages (path scoping is no boundary, ADR-1193; per-surface origins, ADR-1064); the
management API keeps one authentication model, sender-constrained bearer tokens of the control-plane tenant
(ADR-1191); the OP holds no console key and the console holds no OP key, database or signing credential; the console
is a client of the OP exactly as a third party's BFF is (design goal G3); AddIdentityServer and AddIdentityBff
never share a service collection. The console bundle, /bff/* and the forwarded API share the console host's origin.
D2 — Composition. The packable NCode.Identity.ManagementConsole holds the console's server: AddManagementConsole(configuration)
(composes AddIdentityBff, binds ManagementConsole strictly) returning IManagementConsoleBuilder (exposes IdentityBffBuilder), and MapManagementConsole(). The host's Program.cs stays composition-only.
D3 — Topology. The console is served at the root of its origin; its BFF frontend mgmt is the fallback frontend
(no selection rule; cookie __Host-Http-bff.mgmt). The management API is forwarded at /management/api/* to ManagementConsole:ManagementApiUrl (https://localhost:5001/api in Development) with the user token requirement
and resource urn:ncode:management, so the console's API base is /management. /api stays free on the console's
origin, so the hosted platform's manage.<apex> routes /api to the OP for machine callers and every other path to
the console host (amends ADR-1131).
D4 — Client registration.mgmt-console is a confidential client of the control-plane tenant: private_key_jwt, require_pushed_authorization_requests, require_dpop_bound_access_tokens, grant types authorization_code and refresh_token, redirect URI <origin>/bff/callback, post-logout URI <origin>/, back-channel logout URI <origin>/bff/backchannel-logout with backchannel_logout_session_required. It requests profile email offline_access read:clients (the BFF adds openid) and resource urn:ncode:management, loads UserInfo, and
allow-lists preferred_username and email for /bff/user. A deployment registers jwks_uri = <origin>/bff/jwks
(ADR-1294); the developer seed registers the public key by value (amends ADR-1294 and ADR-1146).
D5 — Key rings from PEM files (ADR-1439).Bff:KeyRings:{name}:PemFiles registers a keyed ISecretKeyCollectionProvider
named {name} that holds the EC or RSA private key of each PEM file (PKCS#8, SEC1 or PKCS#1), read through sensitive
buffers; a key's kid is its file name without the extension; a relative path is resolved against the content root;
startup validation requires at least one existing file per ring. A host that registers a keyed provider in code (a
KMS-held key, for example) keeps doing so; the configuration is the built-in source.
D6 — Local development. The AppHost generates per-machine P-256 keys mgmt-client.pem and mgmt-dpop.pem beside
the other development secrets, hands their paths to the console (Bff__KeyRings__…) and the client key's public
JWKS to the OP (DeveloperInteractiveSeed__BffClients__0__Jwks). apps/mgmt is a build-watched bundle (vite build --watch) served by the console host; the Vite dev server on 5173, mgmt-spa, its CORS origin and the Vite /api/
proxy are gone. On a developer machine the OP cannot deliver back-channel logout to the console (loopback guard).
D7 — The browser holds no token (ADR-1438). The console host publishes the console's bootstrap document at /config/bootstrap.json: { "apiBaseUrl": "/management", "bff": { "basePath": "/bff", "antiforgeryHeader": { "name": "X-CSRF", "value": "1" } }, "features": {…}, "branding": {…} }. @ncode/config models RuntimeConfig as the union
of OpenIdRuntimeConfig (kind: 'openid') and BffRuntimeConfig (kind: 'bff'), and loadBffRuntimeConfig fetches
no discovery. @ncode/store has no token slice: a session slice (unknown | signed-in | signed-out, BffUser) fed
by fetchBffUser; the management base query sends the anti-forgery header with same-origin credentials, never Authorization, and dispatches sessionEnded() on 401. @ncode/auth keeps the Playground's protocol helpers only: client.ts, renew.ts and getSubject are deleted.
D8 — Session UX.SessionGate probes /bff/user, polls every 60 s, probes 1 s after min(idleExpiresAt, expiresAt), re-probes when a hidden tab becomes visible, and shares an end through the BroadcastChannel ncode.mgmt.session. Any end returns the console to its sign-in screen ("Your session ended. Sign in to continue.")
whose button navigates to /bff/login?returnUrl=<path>; this refines ADR-1434's "navigate to /bff/login" so a logout
in one tab never races a login in another. Sign-out posts the logout form (sid, returnUrl=/). The BFF's ?error=
shows on the sign-in screen as "Sign-in failed ()."
D9 — Page security. The console host serves index.html for every non-file GET/HEAD path with a fresh nonce in
place of __NCODE_CSP_NONCE__ under script-src 'nonce' 'strict-dynamic'; style-src 'nonce'; img-src 'self' [logo origin]; connect-src 'self'; form-action 'self' <authority origin>; base-uri 'none'; frame-ancestors 'none', no-store
(SetHtmlPageSecurity); /assets/* is immutable. A missing bundle answers 503 and never stops startup. The bundle
builds with the new defineBffServedSurfaceConfig (base /, nonce placeholder); emotion takes the nonce from <meta property="csp-nonce"> through readCspNonce, which lives in @ncode/ui.
D10 — OP bootstrap. The OP's Frontend default names no client and no feature flag; it serves the public-client
and read-only surfaces only.
D11 — Verification. Unit tests per package; an in-process end-to-end test logs in through the OP host into the
console host with the real developer seed; a drift test ties the console's Development configuration to the OP's
seed entry; CI tests the console host against the built console bundle.
Out of scope
A container image, CI image workflow or Helm chart for the console host (Follow-ups).
Forwarded-headers trust and HSTS in the console host (Follow-ups).
Sharing one single-page bundle reader between the interactive surface and the console (Follow-ups); do not touch NCode.Identity.OpenId.Authentication.Interactive.
Selecting the fapi2 profile for mgmt-console, Valkey for the console under the AppHost, any change to the
management API's authentication, the Playground's or conformance dashboard's behavior.
Steps
[ ] 1. Add the Bff:KeyRings options and their validation
Change: [PublicAPI] public sealed class BffKeyRingOptions { public List<string> PemFiles { get; set; } = []; } with
XML docs (section Bff:KeyRings:{name}; each entry is the path of a PEM-encoded EC or RSA private key, relative to
the content root or absolute; a key's id is its file name without the extension). BffOptions gains public Dictionary<string, BffKeyRingOptions> KeyRings { get; set; } = new(StringComparer.Ordinal); (section Bff:KeyRings). BffOptionsValidator.Validate calls a new ValidateKeyRings(options.KeyRings, failures), which adds: Bff:KeyRings must be set. (null); Bff:KeyRings has a key ring without a name. (blank key); Bff:KeyRings:{name}:PemFiles must list at least one file. (null ring or empty list); Bff:KeyRings:{name}:PemFiles:{i} must not be blank.; Bff:KeyRings:{name}:PemFiles:{i} names '{path}', which does not exist.
(resolve with Path.GetFullPath(path, environment.ContentRootPath) and File.Exists).
Tests: one per failure plus Validate_WithExistingPemFiles_Succeeds (write a file in Directory.CreateTempSubdirectory("bff-keys-"), delete it in Dispose); set up ContentRootPath on the
environment mock where the validator reads it.
Pattern: ValidateAntiforgery / ValidateTokens in BffOptionsValidator.cs.
Change: internal static class BffPemKeyRing with public static ISecretKeyCollectionProvider Load(IServiceProvider serviceProvider, string name): read IOptions<BffOptions>.Value.KeyRings[name].PemFiles, IHostEnvironment, ISecretKeyFactory and ISecretKeyCollectionProviderFactory; for each path (resolved as in step 1) read the bytes with File.OpenHandle + RandomAccess.Read into BufferFactory.Rent(length, isSensitive: true, out Span<byte> bytes),
decode with SecureEncoding.UTF8 into a second sensitive buffer of chars, and create the key with factory.CreateEccPem(new KeyMetadata { KeyId = Path.GetFileNameWithoutExtension(path) }, chars); when that throws CryptographicException or ArgumentException, use CreateRsaPem with the same metadata; when both throw, throw InvalidOperationException($"The key ring '{name}' cannot read '{path}': it holds no PEM-encoded EC or RSA private key.").
Return collectionFactory.CreateStatic(keys). In AddBffConfiguration, after the options binding, for each child of section.GetSection("KeyRings").GetChildren() take var name = child.Key; and call builder.ServiceCollection.AddKeyedSingleton<ISecretKeyCollectionProvider>(name, (sp, _) => BffPemKeyRing.Load(sp, name))
(no null-forgiving operator: it fails the build outside tests);
update the method's XML summary to say it also registers the configured key rings.
Tests (real ISecretKeyFactory/ISecretKeyCollectionProviderFactory from AddSecretsLibrary() in a ServiceCollection, disposed with DisposeAsync): an EC PEM and an RSA PEM ring load with key ids equal to the file
names; a non-key file throws naming the ring; AddBffConfiguration with Bff:KeyRings:ring:PemFiles:0 resolves GetRequiredKeyedService<ISecretKeyCollectionProvider>("ring"). Write PEMs with ECDsa.ExportPkcs8PrivateKeyPem() and RSA.ExportRSAPrivateKeyPem() into a temp subdirectory.
Pattern: DevelopmentSecretFile.Read in src/NCode.Identity.OpenId.LocalDev/DevelopmentSecrets/DevelopmentSecretFile.cs;
the keyed ring in IT/Bff/BffTestHost.cs.
Change: [PublicAPI] public sealed class ManagementConsoleOptions (section ManagementConsole): string? ManagementApiUrl (absolute URL of the OP's management API base, such as https://id.example.com/api), string? BundleRootPath (directory of the built apps/mgmt bundle; relative to the content root or absolute), Dictionary<string, bool> Features { get; set; } = new(StringComparer.Ordinal), ManagementConsoleBrandingOptions Branding { get; set; } = new(). ManagementConsoleBrandingOptions: DisplayName, LogoUrl, PrimaryColor, SecondaryColor, Mode (all string?). internal sealed class ManagementConsoleOptionsValidator(IHostEnvironment environment) : IValidateOptions<ManagementConsoleOptions> fails with: ManagementConsole:ManagementApiUrl must be an absolute https URL without user info, query or fragment. (http
accepted only when environment.IsDevelopment()); ManagementConsole:BundleRootPath must name the directory of the built apps/mgmt bundle.
(blank); ManagementConsole:Branding:LogoUrl must be an absolute https URL.; ManagementConsole:Branding:Mode must be 'light' or 'dark'.; ManagementConsole:Features has a feature without a name.
Tests: one per failure, the Development http case and a valid set.
Change: sealed records serialized with [JsonPropertyName] camelCase names: document apiBaseUrl, bff, features, branding (omitted when null); bff basePath, antiforgeryHeader; header name, value; branding displayName, logoUrl, primaryColor, secondaryColor, mode (each omitted when null or blank; the whole branding is null when
every value is). ManagementConsoleBootstrapDocument.Create(ManagementConsoleOptions options, BffOptions bff, PathString pathBase): apiBaseUrl = pathBase + ManagementConsoleDefaults.ApiBasePath; basePath = pathBase + bff.Frontends[ManagementConsoleDefaults.FrontendName].PathPrefix; header from bff.Antiforgery. internal sealed class ManagementConsoleBootstrapHandler(IOptions<ManagementConsoleOptions>, IOptions<BffOptions>) with public IResult Handle(HttpContext httpContext): httpContext.Response.SetNoStore() then TypedResults.Json(document, JsonSerializerOptions.Web).
Tests: the document for defaults (/management, /bff, X-CSRF/1), a path base /console, a custom prefix and
header, branding omitted when empty, and the handler's Cache-Control: no-store (execute the IResult against a DefaultHttpContext with a MemoryStream body and a service provider that has AddLogging()).
Pattern: src/NCode.Identity.Server/Frontend/FrontendBootstrapDocument.cs and FrontendBootstrapEndpointProvider.cs.
Change: internal sealed class ManagementConsoleBundleReader(IOptions<ManagementConsoleOptions> options, IHostEnvironment environment) : IDisposable
— the root is Path.GetFullPath(options.BundleRootPath, environment.ContentRootPath); ValueTask<string?> TryReadIndexAsync(CancellationToken) returns index.html's text or null when missing, cached in a ManagementConsoleBundleIndex(DateTime LastWriteTimeUtc, long Length, string Html) record and re-read when the file's
last-write time or length changes; IFileInfo? TryGetAsset(string assetPath) serves only the assets directory
through a lazily created PhysicalFileProvider; public static string RenderIndex(string html, string nonce) returns html.Replace(ManagementConsoleDefaults.NoncePlaceholder, nonce, StringComparison.Ordinal). internal readonly record struct ManagementConsolePageSecurity(string Nonce, HtmlPageSecurityPolicy Policy) with public static ManagementConsolePageSecurity Create(Uri authority, string? logoUrl): a fresh ContentSecurityPolicySources.CreateNonce(); directives script-src (nonce, StrictDynamic), style-src (nonce), img-src (Self, plus ContentSecurityPolicySources.Origin(logo) when logoUrl parses as an absolute URI), connect-src (Self), form-action (Self, ContentSecurityPolicySources.Origin(authority)); leave base-uri
and framing at their strict defaults.
Tests: reader — missing bundle, cached read, re-read after a rewrite with a new length, an asset inside assets, a
path that leaves it, RenderIndex replacing every placeholder; security — the exact header value default-src 'none'; script-src 'nonce-<n>' 'strict-dynamic'; style-src 'nonce-<n>'; img-src 'self'; connect-src 'self'; form-action 'self' https://id.example; base-uri 'none'; frame-ancestors 'none'
and the logo origin case.
Change: ManagementConsolePageHandler(ManagementConsoleBundleReader reader, IOptions<BffOptions> bff, IOptions<ManagementConsoleOptions> options)
with public async Task<IResult> HandleAsync(HttpContext httpContext): no index → SetNoStore() and TypedResults.Problem(statusCode: 503, title: "The management console is not built.", detail: "Build apps/mgmt (pnpm --filter @ncode/app-mgmt build) and set ManagementConsole:BundleRootPath to its dist directory.");
otherwise security = ManagementConsolePageSecurity.Create(new Uri(bff.Value.Frontends["mgmt"].Authority), options.Value.Branding.LogoUrl), httpContext.Response.SetHtmlPageSecurity(security.Policy) and TypedResults.Text(ManagementConsoleBundleReader.RenderIndex(html, security.Nonce), "text/html; charset=utf-8"). ManagementConsoleAssetHandler(ManagementConsoleBundleReader reader) with public IResult Handle(HttpContext httpContext):
the asset route value → reader.TryGetAsset; unknown file or content type → 404; otherwise Cache-Control: public, max-age=31536000, immutable and TypedResults.PhysicalFile(...) with last-modified, entity tag
and range processing.
Tests: 503 problem with no-store; 200 page whose body carries the CSP header's nonce and no placeholder; the asset's
immutable cache header; 404 for a missing or unknown-type asset.
Change: [PublicAPI] public static class ManagementConsoleEndpointRouteBuilderExtensions with extension(IEndpointRouteBuilder endpoints) → public RouteGroupBuilder MapManagementConsole() that, in order:
calls endpoints.MapBffEndpoints() and endpoints.MapBffRemoteApis(); maps endpoints.MapBffRemoteApi(ManagementConsoleDefaults.ApiPath, managementApiUrl, api => { api.TokenRequirement = BffTokenRequirementNames.User; api.Resource = ManagementConsoleDefaults.ManagementApiResource; }).RequireBffFrontend(ManagementConsoleDefaults.FrontendName)
where managementApiUrl is IOptions<ManagementConsoleOptions>.Value.ManagementApiUrl or, when it is null, throw new InvalidOperationException("ManagementConsole:ManagementApiUrl must be set.") (no null-forgiving operator);
creates group = endpoints.MapGroup(string.Empty).AllowAnonymous(); maps on it GET BootstrapPath → ManagementConsoleBootstrapHandler.Handle (name ManagementConsole.Bootstrap), GET|HEAD /assets/{**asset} → ManagementConsoleAssetHandler.Handle (name ManagementConsole.Asset), and group.MapFallback("{*path:nonfile}", …PageHandler.HandleAsync).WithMetadata(new HttpMethodMetadata([HttpMethods.Get, HttpMethods.Head]))
(name ManagementConsole.Page); excludes the group from the API description; returns the group. Resolve the handlers
from context.RequestServices inside static lambdas, as MapBffEndpoints does. XML docs say what each route serves.
Change: reference MCH. ManagementConsoleApplicationFactory : WebApplicationFactory<ManagementConsoleHostMarker>
with constructor arguments HostApplicationFactory? provider = null, ECDsa? clientKey = null: Development environment; configuration
overlay Bff:Frontends:mgmt:Authority = HostApplicationFactory.TestServerIssuer, ManagementConsole:ManagementApiUrl
= http://localhost/api, ManagementConsole:BundleRootPath = a temp subdirectory holding an index.html with <meta property="csp-nonce" nonce="__NCODE_CSP_NONCE__">, an empty <div id="root"></div> and a nonce'd module
script, plus assets/index-test.js; test services: keyed ISecretKeyCollectionProvidermgmt-client and mgmt-dpop from P-256 ECDsa keys (CreateEcc(new KeyMetadata { KeyId = "mgmt-client" }, key); the client key is clientKey when given, otherwise one the factory creates and owns), and, when provider is set, AddHttpClient(OpenIdClientDefaults.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => provider.Server.CreateHandler()). CreateBrowserClient() returns a client with base https://localhost:5005/, no redirects, no cookie handling.
Dispose the keys and the directory in DisposeAsync.
Tests: GetPage_AtAConsoleRoute_ServesTheBundleUnderAFreshNonce (/tenants/acme/clients: 200, nonce in body equals
the CSP's, no placeholder, frame-ancestors 'none', form-action 'self' http://localhost, no-store); asset →
immutable; GetBootstrap_Always_PublishesTheBffDocument (/management, /bff, X-CSRF, 1, branding display name); /bff/user and /management/api/caller with X-CSRF: 1 and no cookie → 401; /management/api/caller without the
header → 403.
Change: [PublicAPI] public sealed class DeveloperBffClientSeed: string? ClientId, string? Jwks (JSON text of the
public JSON Web Key Set registered by value; supplied from outside the repository, such as the AppHost), string[] RedirectUris = [], string[] PostLogoutRedirectUris = [], string? BackchannelLogoutUri, bool ShouldSeed => !string.IsNullOrEmpty(ClientId) && !string.IsNullOrEmpty(Jwks) && RedirectUris.Length > 0.
Its XML summary: a development-only confidential BFF client (private_key_jwt, PAR, DPoP-bound tokens, back-channel
logout) whose keys are registered by value because the server's outbound guard never reaches a loopback jwks_uri. DeveloperInteractiveSeedOptions gains public IList<DeveloperBffClientSeed> BffClients { get; set; } = []; (docs as SpaClients), and its class summary names confidential BFF clients beside the public SPA clients. AddDeveloperInteractiveSeed's hasSeedData also counts options.BffClients.Count > 0; its XML docs and the
registration's class summary say it seeds the BFF clients too.
Tests: BffClients_ByDefault_IsEmpty, BffClients_WhenBoundFromConfiguration_HoldsExactlyTheConfiguredClients, and ShouldSeed false without a client id, a key set or a redirect URI, true with all three.
Change: the handler's primary constructor also takes IClientKeyImporter clientKeyImporter and TimeProvider timeProvider. HandleAsync seeds each BffClients entry whose ShouldSeed is true, after the SPA clients, through a new SeedBffClientAsync: skip an existing client; settings redirect_uris, post_logout_redirect_uris (when any), backchannel_logout_uri + backchannel_logout_session_required = true (when set), token_endpoint_auth_method = private_key_jwt, grant_types_supported = [authorization_code, refresh_token], require_pushed_authorization_requests = true, require_dpop_bound_access_tokens = true (use the OpenIdSettingNames / OpenIdProtocol constants); SeededClientValidator.EnsureCanBeSeededAsync("developer BFF client", …);
secrets = the keys clientKeyImporter.TryImportRegisteredKeySet(JsonElement.Parse(jwks), timeProvider.GetUtcNow(), out keys, out error)
imports; an unparsable or refused key set throws InvalidOperationException naming the client, as the conformance seed does.
Update the class summary to name the BFF clients.
Tests: a seeded BFF client's stored settings and secrets; an existing client is left alone; an invalid key set throws
naming the client; an entry that should not seed is skipped. Follow the file's strict-mock scaffold.
Pattern: SeedClientAsync / ImportKeys in src/NCode.Identity.Server/ConformanceEnvironment/ConformanceSeedHandler.cs.
Change: in the overlay, delete Frontend:ClientId and Frontend:Features, the mgmt-spa entry of DeveloperInteractiveSeed:SpaClients and https://localhost:5173 from Cors:AllowedOrigins; add DeveloperInteractiveSeed:BffClients = [{ "ClientId": "mgmt-console", "RedirectUris": ["https://localhost:5005/bff/callback"], "PostLogoutRedirectUris": ["https://localhost:5005/"], "BackchannelLogoutUri": "https://localhost:5005/bff/backchannel-logout" }]
(no Jwks: the AppHost supplies it). In FrontChannelCorsTests set AllowedOrigin to https://localhost:5174. In
the XML docs of FrontendBootstrapOptions (Issuer, Surfaces) and FrontendIssuerResolver, replace "served to the
Management console" / "which serves the Management console" with wording that the default document serves every
surface that has no Surfaces entry, and that the root tenant serves the surfaces under the dynamic strategies.
Change: ManagementConsoleSeedingHostApplicationFactory(string consoleClientJwks) : HostApplicationFactory with SeedsDeveloperClients => true and a configuration overlay DeveloperInteractiveSeed:BffClients:0:Jwks = consoleClientJwks (pattern ConfiguredHostApplicationFactory.cs). The test creates a P-256 ECDsa client key, the
JWKS {"keys":[{"kty":"EC","crv":"P-256","x":…,"y":…,"kid":"mgmt-client","use":"sig","alg":"ES256"}]} from key.ExportParameters(false) (Base64Url), the OP factory with that JWKS, and the console factory with provider = the OP factory and clientKey = the key; it seeds a local account (SeedLocalAccountAsync), signs the browser in at the OP
(InteractiveLogin.SignInAsync), then: GET /bff/login?returnUrl=%2Fteams → 302 to the OP; the authorize request with
the OP session cookie → 302 to https://localhost:5005/bff/callback…; the callback → 302 to /teams with a __Host-Http-bff.mgmt cookie; GET /bff/user with the cookie and X-CSRF: 1 → 200 whose JSON has sub and logoutSid.
Keep cookies in a dictionary as BffTestHost.SendAsync does.
Pattern: Login_ThenUserAndApi_Succeed in IT/Bff/BffEndToEndTests.cs.
Change: one class with a DeveloperSeedingHostApplicationFactory class fixture and a ManagementConsoleApplicationFactory
(step 11; it overlays only the authority, the API URL and the bundle root, so ClientId, PublicOrigin and PathPrefix come from the console host's own appsettings*.json). Read the console's IOptions<BffOptions>.Value.Frontends["mgmt"] and the OP's DeveloperInteractiveSeedOptions. Assert that the OP's BffClients holds one entry whose ClientId equals the console's ClientId, whose RedirectUris contain PublicOrigin + PathPrefix + "/callback", whose PostLogoutRedirectUris contain PublicOrigin + "/", and whose BackchannelLogoutUri equals PublicOrigin + PathPrefix + "/backchannel-logout".
Change: internal static class DevelopmentKeyFile with internal static string EnsureEcPrivateKey(string directory, string fileName)
— the same create-once contract as DevelopmentSecretFile.Ensure (temporary file, CreateNew, user-only Unix mode, DevelopmentSecretFile.MoveIntoPlace, an empty file is replaced), writing a P-256 key as PKCS#8 PEM through ECDsa.TryExportPkcs8PrivateKeyPem into a rented sensitive buffer; and internal static string ReadPublicJwks(string path)
— the JSON text {"keys":[{"kty":"EC","crv":"P-256","x":…,"y":…,"kid":<file name without extension>,"use":"sig","alg":"ES256"}]}
written with Utf8JsonWriter and Base64Url coordinates. XML docs say why: the OP registers the console's public key
by value because its outbound guard never fetches a loopback jwks_uri.
Tests: creates once and reuses; replaces an empty file; the JWKS parses, its key id is the file stem and its
coordinates match the PEM's public key.
Pattern: DevelopmentSecretFile.cs and DevelopmentSecretFileTests.cs.
Change: reference MCH. After the admin password: consoleClientKeyPath = DevelopmentKeyFile.EnsureEcPrivateKey(developmentSecretsDirectory, "mgmt-client.pem"), consoleDPoPKeyPath = … "mgmt-dpop.pem", consoleClientJwks = DevelopmentKeyFile.ReadPublicJwks(consoleClientKeyPath); api gets .WithEnvironment("DeveloperInteractiveSeed__BffClients__0__Jwks", consoleClientJwks). Replace the AddViteApp("mgmt", …) block with builder.AddJavaScriptApp("mgmt", "../frontend/apps/mgmt").WithPnpm(install: false);
and add builder.AddProject<Projects.NCode_Identity_ManagementConsole_Host>("console").WithHttpHealthCheck("/health/ready").WithEnvironment("Bff__KeyRings__mgmt-client__PemFiles__0", consoleClientKeyPath).WithEnvironment("Bff__KeyRings__mgmt-dpop__PemFiles__0", consoleDPoPKeyPath).WaitFor(api).WithExternalHttpEndpoints();.
Rewrite the comments: mgmt is a build-watcher whose dist the console host serves (like auth); console is the
console's BFF host on https://localhost:5005, a confidential client of the api whose key the AppHost generates;
the playground comment describes its own pins without naming mgmt; the header comment of the key files sits beside the
root-key comment. Keep the file declarative.
Change: add export type BffServedSurfaceConfigOptions = { readonly coverage: CoverageFloors }; and export function defineBffServedSurfaceConfig({ coverage }: BffServedSurfaceConfigOptions): ViteUserConfig returning { base: '/', html: { cspNonce: CSP_NONCE_PLACEHOLDER }, plugins: [react(), failOnOversizedChunks()], build: buildOptions(), test: testGates({ environment: 'jsdom', coverage }) },
documented as the configuration of a surface its own BFF host serves at the root of its origin, under the page's CSP
nonce, with no dev server (its dev script rebuilds dist). Delete API_PATH and the /api/ proxy entry from defineStandaloneSurfaceConfig and its doc sentence about the management API (no standalone surface calls it); the CSP_NONCE_PLACEHOLDER doc names the console host's ManagementConsoleDefaults.NoncePlaceholder beside InteractiveBundleShell.NoncePlaceholder. package.json description: "…the test gates and the standalone,
backend-served and BFF-served surface presets."
Tests: the standalone proxy holds only /config/bootstrap.json; a describe('defineBffServedSurfaceConfig') block
mirroring the backend-served one (base /, nonce placeholder, no server, jsdom test gates).
Change: types.ts: rename the resolved OpenID type to OpenIdRuntimeConfig and add readonly kind: 'openid'; add AntiforgeryHeader = { readonly name: string; readonly value: string }, BffConfig = { readonly basePath: string; readonly antiforgeryHeader: AntiforgeryHeader }, BffBootstrapDocument = { readonly apiBaseUrl: string; readonly bff: BffConfig; readonly features?: …; readonly branding?: BrandingConfig }, BffRuntimeConfig = { readonly kind: 'bff'; readonly apiBaseUrl: string; readonly bff: BffConfig; readonly features: Readonly<Record<string, boolean>>; readonly branding?: BrandingConfig }
and export type RuntimeConfig = OpenIdRuntimeConfig | BffRuntimeConfig;. Update the module and BootstrapDocument
docs (an OpenID surface's document names the public client; a BFF-served surface's document names its BFF and no
token-related value). parse.ts: mergeRuntimeConfig returns OpenIdRuntimeConfig with kind: 'openid'; add parseBffBootstrapDocument(raw: unknown): BffBootstrapDocument (object required; apiBaseUrl through requireNonEmpty + normalizeBaseUrl; bff object required — error Bootstrap document is missing required field 'bff'.; bff.basePath non-empty, starting with /, normalized; bff.antiforgeryHeader.name and .value non-empty, with field
names bff.basePath, bff.antiforgeryHeader.name, bff.antiforgeryHeader.value; features and branding as parseBootstrapDocument reads them) and toBffRuntimeConfig(document: BffBootstrapDocument): BffRuntimeConfig.
Tests: kind: 'openid' in every merged expectation; BFF parsing of a full document, each missing field, a base path
without a leading slash, and toBffRuntimeConfig defaults (features: {}, no branding).
Change: loadRuntimeConfig returns Promise<OpenIdRuntimeConfig>. Add loadBffRuntimeConfig(options: Omit<LoadRuntimeConfigOptions, 'surface'> = {}): Promise<BffRuntimeConfig> — the same
fetch and origin resolution, fetching only ${origin}${BOOTSTRAP_PATH} (no surface, no discovery), then toBffRuntimeConfig(parseBffBootstrapDocument(raw)); and createBffRuntimeConfigLoader(options: Omit<LoadRuntimeConfigOptions, 'surface' | 'signal'> = {}): RuntimeConfigLoader. RuntimeConfigLoader stays (signal: AbortSignal) => Promise<RuntimeConfig> (the union). Export the new types and
functions from index.ts (AntiforgeryHeader, BffBootstrapDocument, BffConfig, BffRuntimeConfig, OpenIdRuntimeConfig, parseBffBootstrapDocument, toBffRuntimeConfig, loadBffRuntimeConfig, createBffRuntimeConfigLoader).
Tests: the BFF loader fetches exactly one URL, honors the origin default and the abort signal, surfaces an HTTP
failure, and the bound loader passes its signal.
Change: every RuntimeConfig type in these files becomes OpenIdRuntimeConfig; App renders nothing unless runtime?.kind === 'openid'; every runtime literal in the tests gains kind: 'openid'; App.test.tsx adds a case
where a BFF runtime renders nothing.
Change: as step 22 for the conformance app (OpenIdRuntimeConfig, runtime?.kind === 'openid', kind: 'openid'
literals, a BFF-runtime case in App.test.tsx); the gate's test runtime literal gains kind: 'openid', and the BootstrapGate doc comment ("there is no API base URL or issuer") reads "no API base URL" only.
[ ] 24. Share the CSP nonce reader through @ncode/ui
Files: FE/packages/ui/src/cspNonce.ts (new; the content of FE/apps/auth/src/cspNonce.ts), FE/packages/ui/src/cspNonce.test.ts (new; the content of FE/apps/auth/src/cspNonce.test.ts), FE/packages/ui/src/index.ts, FE/apps/auth/src/mount.tsx, FE/apps/auth/src/cspNonce.ts and FE/apps/auth/src/cspNonce.test.ts (both deleted)
Change: move the two files with git mv; export CSP_NONCE_SELECTOR and readCspNonce from @ncode/ui's index; mount.tsx imports readCspNonce from @ncode/ui. The doc comment says the host that serves a bundle writes the
nonce, so it holds for the OP and the console host.
Plan snapshot, part 2 of 2 (steps 25–48, the review checklist and the follow-ups). The working copy is PLAN.md in the plan's checkout; this comment is not updated as steps run.
Plan: feat(mgmt): serve the management console through its own BFF host (48 steps) — part 2 of 2
Change: ApiRootState = { readonly config: { readonly runtime: RuntimeConfig | null } }. dynamicBaseQuery: null
runtime → the existing CUSTOM_ERROR; runtime.kind !== 'bff' → CUSTOM_ERROR'The management API is called only from a surface its BFF serves.';
otherwise fetchBaseQuery({ baseUrl: \${runtime.apiBaseUrl}/`, credentials: 'same-origin', prepareHeaders: (headers) => { headers.set(name, value); return headers; } })with the anti-forgery header, and after the callif (result.error?.status === 401) api.dispatch(sessionEnded());. Rewrite its doc comments (the cookie and the header authenticate the call; the browser holds no token). The root reducer holds session: sessionSlice.reducerin place of the auth slice;index.tsexportssessionSlice, sessionStarted, sessionEnded, SessionState, SessionStatus, BffUser, BffSessionLifetime, parseBffUser, fetchBffUser and no auth symbol. Delete the auth slice files (git rm`).
Tests: base query — no fetch without runtime, CUSTOM_ERROR for an OpenID runtime, base URL resolved at request
time, the anti-forgery header sent and no authorization header, credentials same-origin, a 401 ends the session,
a 500 does not; store — initial session state and preloaded session; configSlice.test.ts literals gain kind.
Change: git rm the client and renewal modules and their tests; delete getSubject and its tests from jwt.ts;
in flow.test.ts the sample client id mgmt-spa becomes playground-spa; index.ts exports only pkce.js, flow.js and decodeJwtClaims; package.json description: "Framework-free OAuth
2.0 authorization-code + PKCE protocol helpers for the Playground's public client." Only @ncode/app-mgmt imports
the deleted symbols, and steps 29–31 remove those imports.
Change: export SESSION_POLL_INTERVAL_MS = 60_000, SESSION_CHANNEL_NAME = 'ncode.mgmt.session', SESSION_ENDED_MESSAGE = 'ended', SESSION_ENDED_NOTICE = 'Your session ended. Sign in to continue.', and: loginUrl(runtime: BffRuntimeConfig, returnPath: string): string → ${runtime.bff.basePath}/login?returnUrl=${encodeURIComponent(returnPath)}; submitLogout(user: BffUser, doc: Document = document): void — appends to doc.body a hidden <form method="post" action={user.logoutUrl}> with hidden inputs sid = user.logoutSid and returnUrl = /, and
calls form.submit() (a form post is a top-level navigation that follows the redirect to the OP); readSignInError(search: string): string | undefined → the error query parameter, if any; signInErrorMessage(code: string): string → Sign-in failed (${code}).; nextProbeDelayMs(user: BffUser, now: number = Date.now()): number → Math.max(0, Math.min(user.session.idleExpiresAt, user.session.expiresAt) * 1000 - now) + 1000; openSessionChannel(onEnded: () => void): { close(): void } — when globalThis.BroadcastChannel exists, a channel
named SESSION_CHANNEL_NAME that calls onEnded for SESSION_ENDED_MESSAGE; otherwise a no-op; announceSessionEnded(): void — opens a channel, posts SESSION_ENDED_MESSAGE, closes it (no-op without the API).
A header comment cites ADR-1434 and ADR-1438.
Tests: each function, the form's method/action/inputs (spy on HTMLFormElement.prototype.submit), the channel with
a fake BroadcastChannel installed by vi.stubGlobal (message delivered, other message ignored, close) and without one.
Change: export function SessionGate({ children }: { children: ReactNode }). Reads config.runtime and session.
A runtime whose kind is not 'bff' renders <BootstrapScreen error="The management console must be served by its BFF host." />.
On mount it probes (fetchBffUser(runtime) → sessionStarted(user) or sessionEnded(); a thrown probe sets the
local error 'Unable to check your session.'); results after unmount are ignored. The ?error= code is read once
(useState initializer with readSignInError(window.location.search)) and removed from the address bar with window.history.replaceState(null, '', window.location.pathname). While signed-in: an interval probe every SESSION_POLL_INTERVAL_MS, a timeout probe after nextProbeDelayMs(user), a visibilitychange listener that probes
when document.visibilityState === 'visible', and openSessionChannel(...) whose callback marks a ref endedByBroadcast and dispatches sessionEnded(); all cleaned up. A transition from signed-in to signed-out
that the channel did not cause calls announceSessionEnded(); either way the gate remembers that a session ended.
Render: signed-in → children; unknown → <SignInScreen isBusy …/>; signed-out → <SignInScreen …/> with error = the sign-in error message, else SESSION_ENDED_NOTICE after an ended session, else the probe error; its onSignIn sets busy and calls window.location.assign(loginUrl(runtime, window.location.pathname)). Pass productName from the runtime's branding as AuthGate does.
Tests (fake timers where needed, fetch stubbed with vi.stubGlobal, window.location replaced as App.test.tsx
does): busy while probing; children after a user; sign-in after a 401 and the login navigation with returnUrl;
the ?error= message and its removal; the probe-failure message; the BFF-host error for an OpenID runtime; the poll,
the expiry probe and the visibility probe; a broadcast ends the session with the notice and no re-announce; a sessionEnded() dispatched by the store announces once.
Pattern: MG/src/AuthGate.tsx (structure, SignInScreen props) and MG/src/AuthGate.test.tsx (setup).
Change: App reads const user = useAppSelector((state) => state.session.user) and const isSignedIn = useAppSelector((state) => state.session.status === 'signed-in'); the subject label is user.name, else the preferred_username claim when it is a string, else user.sub; handleSignOut calls announceSessionEnded() then submitLogout(user) when a user is present. Delete the runtime/ID-token reads, the signedOut dispatch and endSessionUrlFromRuntime, and rewrite the sign-out comment (the form post ends the BFF
session, revokes its refresh token and runs RP-initiated logout at the OP).
Tests: the runtime literal becomes the BFF literal of step 27 with apiBaseUrl: 'https://api.test'; stores preload session: { status: 'signed-in', user } with a BffUser fixture (logoutUrl: '/bff/logout', logoutSid: 'guard')
in place of auth. Replace the session tests: the label shows the name, the preferred_username claim, or the
subject; sign-out submits a form to /bff/logout carrying sid=guard and returnUrl=/ (spy on HTMLFormElement.prototype.submit); no session controls when signed out.
Change (the same mechanical edit in each file): replace the RuntimeConfig literal (apiBaseUrl, issuer, authorizationEndpoint, tokenEndpoint, scopes, features) with const runtime: BffRuntimeConfig = { kind: 'bff', apiBaseUrl: 'https://api.test', bff: { basePath: '/bff', antiforgeryHeader: { name: 'X-CSRF', value: '1' } }, features: {} };
keeping the file's own apiBaseUrl value; import BffRuntimeConfig in place of RuntimeConfig; delete the auth: { … }
member of every createAppStore({ … }) call. Change nothing else.
Files: any frontend file the commands below reformat or report (fix each report in the file it names, within the
plan's design; escalate when a fix needs a decision)
Change: from src/frontend run npx -y pnpm@10.34.7 run format (Prettier writes), make sure no file under src/frontend/apps/mgmt/src mentions accessToken, Bearer, @ncode/auth or AuthGate, then run the gate
below; the @ncode/app-mgmt coverage floors hold (97/95/100/97).
Change: BuiltConsoleBundleFactAttribute like tests/NCode.Identity.OpenId.Authentication.Interactive.Tests/BuiltAuthBundleFactAttribute.cs
with variable NCODE_MGMT_BUNDLE_INDEX and the apps/mgmt wording. Add [BuiltConsoleBundleFact] public async Task RenderIndex_WhenGivenTheBuiltBundlesIndex_LeavesNoPlaceholderAndNoncesEveryScript():
read the file, render with nonce n0nce, assert no __NCODE_CSP_NONCE__ remains, the csp-nonce meta carries nonce="n0nce", and every <script tag carries nonce="n0nce". In frontend.yml, after "Test the host against
the built login bundle", add the step "Test the console host against the built console bundle" with NCODE_MGMT_BUNDLE_INDEX: ${{ github.workspace }}/src/frontend/apps/mgmt/dist/index.html running dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj --nologo --filter "FullyQualifiedName~ManagementConsoleBundleReaderTests.RenderIndex_WhenGivenTheBuiltBundlesIndex" -- RunConfiguration.TreatNoTestsAsError=true
(same if, shell and working directory as the login-bundle step).
Change: from docs/decisions/0000-template.md. Title # 1437. The management console is a confidential client served by a BFF host of its own. Status Accepted (amends [ADR-1064](…), [ADR-1071](…), [ADR-1131](…), [ADR-1146](…), [ADR-1294](…)),
Date 2026-11-05, Deciders NCode Group. Context (present tense): the console is the family's most privileged surface;
a browser public client holds its tokens where injected script reads them (RFC 10017 §5); the BFF library serves
several frontends (ADR-1432); the design's §11 question 6 asks whether the console's BFF runs in the OP host or a
separate host. Decision: D1 (with its reasons as a list), D2, D3 (including the manage.<apex> routing: /api to the
OP for machine callers, every other path to the console host), D4 and D6. Options considered: (a) the BFF in the OP
host with the console under /mgmt/ — rejected: the console's script would share an origin with the OP's login,
consent and account pages, and the OP would carry a second composition and a client's keys; (b) the BFF in the OP
process on a host name of its own — rejected: origin isolation without process isolation, and AddIdentityServer and AddIdentityBff in one service collection; (c) a separate host (chosen). Consequences: one more deployable (its image
and chart are follow-up work), the console reaches the OP over HTTP like any client, local development runs three
processes for the console (api, console, mgmt build-watcher), back-channel logout is not delivered on a
developer machine. References: docs/backend-for-frontend.md §5.14 and §11, RFC 10017, the package and host paths,
issue #158.
Change: Title # 1438. The management console holds no token and reads its session from its BFF. Status Accepted (amends [ADR-1312](…)). Context: what a BFF-served bundle needs at boot (its API base, its BFF's prefix
and anti-forgery header) and does not (an issuer, a client id, scopes); ADR-1434's open-tabs pattern; a logout in one
tab racing a login in another. Decision: D7, D8 and D9 as numbered points (the bootstrap document shape, the RuntimeConfig union and loadBffRuntimeConfig, the session slice and fetchBffUser, the base query's header and 401, the deleted @ncode/auth modules, SessionGate's probes and channel, the sign-in screen as the end state,
the logout form, the page CSP and defineBffServedSurfaceConfig); state that standalone dev servers proxy only the
bootstrap document (the ADR-1312 amendment). Options considered: navigating to /bff/login on every end (rejected:
the race, and a failing login would loop); keeping @ncode/auth's custody with a token-mediating backend (rejected:
RFC 10017 §6.2 leaves tokens in the browser); one RuntimeConfig with optional OpenID and BFF members (rejected:
every consumer would test for members its surface always has). Consequences, References (ADR-1434, ADR-1437, RFC 10017).
Change: Title # 1439. A BFF loads the key rings its configuration names from PEM files. Status Accepted.
Context: a frontend names its client and DPoP key rings (ClientAuthentication:KeyRing, DPoP:KeyRing), and a BFF
host had to register each keyed ISecretKeyCollectionProvider in code; a stateless BFF replica takes its keys from a
mounted secret. Decision: D5 in full (section, formats, EC-then-RSA reading, kid = file stem, sensitive buffers,
content-root resolution, startup validation messages, code registration as the seam for other sources). Options:
keys in the configuration's text (rejected: a private key in configuration values ends up in logs and dumps of the
configuration); a JWK set file (rejected: PEM is what tooling and secret stores emit); a Source vocabulary for key
rings (rejected for now as unneeded: keyed DI already is the open seam). Consequences, References (ADR-1290, ADR-1382, src/NCode.Identity.Bff/Secrets/BffPemKeyRing.cs).
Change: append index rows for 1437 (Accepted (amends [1064](…), [1071](…), [1131](…), [1146](…), [1294](…))), 1438
(Accepted (amends [1312](…))) and 1439 (Accepted) after 1434, in the existing row format. Extend each amended
record's Status line (and its index row) with one clause, and change nothing else in the record: 1064 — "and by
ADR-1437: the management console is served by a BFF host of its own"; 1071 — "amended by ADR-1437: the AppHost runs
the management console's BFF host on https://localhost:5005 and build-watches apps/mgmt" (read its Status line
first and join the clause in its existing style); 1131 — "Accepted (amended by ADR-1437: manage.<apex> serves the
management console's BFF host, and routes /api to the management API)"; 1146 — "and by ADR-1437: the seeder also
registers the confidential BFF clients its configuration declares"; 1294 — "and by ADR-1437: a client whose provider
cannot reach its jwks_uri registers its keys by value"; 1312 — "Accepted (amended by ADR-1438: the standalone dev
servers proxy only the bootstrap document)".
[ ] 43. Describe the console in the BFF design (status, §1.2, §4.1, §5.12)
Files: docs/backend-for-frontend.md
Change: Status bullet → "In progress. SharedState, the protocol client, the NCode.Identity.Bff core, remote APIs,
browser hardening, multiple frontends and the management console are implemented (§9).
The library is tracked as issue [Backend for Frontend (BFF) library: cookie sessions with server-side token custody #123]…; each implemented area's decisions are ADRs." (keep the Backend for Frontend (BFF) library: cookie sessions with server-side token custody #123 link; delete the
sentence that calls the console pending and its Serve the management console through the BFF instead of as a browser public client #158 link). §1.2's last paragraph → present tense: the family's relying party is NCode.Identity.Bff,
and its own management console is a BFF frontend served by NCode.Identity.ManagementConsole.Host, so the most
privileged surface holds no token in the browser (§5.14). §4.1 table: add rows NCode.Identity.ManagementConsole (depends on NCode.Identity.Bff.Server, NCode.AspNetCore; holds AddManagementConsole, MapManagementConsole, the console's bootstrap document, page and assets — ADR-1437) and NCode.Identity.ManagementConsole.Host (the console's reference host). §5.12: after the composition sketch, a
paragraph and a jsonc snippet for Bff:KeyRings:{name}:PemFiles (D5, ADR-1439), and change the sketch's AddKeyedSingleton<ISecretKeyCollectionProvider>(…) comment to say it is the seam for a ring held elsewhere, such as
a KMS; add the key-ring failures to the startup-validation list.
[ ] 44. Describe the console in the BFF design (§5.14, §9, §11)
Files: docs/backend-for-frontend.md
Change: add ### 5.14 The management console after §5.13: the topology (D1, D3) as a short list, a table of the
console host's paths (/ and every non-file path → the bundle page under a nonce CSP; /assets/* → immutable
files; /config/bootstrap.json → the console's bootstrap document; /bff/* → the mgmt frontend's endpoints; /management/api/* → the OP's management API with the user's DPoP-bound urn:ncode:management token), the client
registration (D4), local development (D6), and the browser side (D7, D8), citing ADR-1437, ADR-1438, ADR-1439. §9:
replace the "Pending" row with a "Management console" row ("NCode.Identity.ManagementConsole.Host serves apps/mgmt
as the confidential client mgmt-console of the control-plane tenant (§5.14,
[ADR-1437]…, [ADR-1438]…). The Playground stays a public client on purpose.") and add a "Key rings" row (ADR-1439).
§11 question 6: append "Decided: a separate host, NCode.Identity.ManagementConsole.Host, on an origin of its own
([ADR-1437]…)." and drop "(simplest)" / "(cleaner separation …)" from the question.
[ ] 45. Describe the console in the frontend README
Files: src/frontend/README.md
Change: layout line for mgmt/ → "Management console (served by its BFF host, NCode.Identity.ManagementConsole.Host)";
the comment of the pnpm --filter @ncode/app-mgmt dev line becomes "build-watch the Management console's dist (its
BFF host serves it)". Serving models: add a BFF-served SPAs bullet (apps/mgmt, vite build --watch, defineBffServedSurfaceConfig({ coverage }), base /, nonce placeholder, served by the console host) and make the
standalone bullet's example apps/playground. Runtime configuration: show the BFF document beside the OpenID one, createBffRuntimeConfigLoader(), the kind union; the standalone dev servers proxy only /config/bootstrap.json;
the default document (no ?surface=) is the OP's default, not the console's. Replace "Authentication
(authorization-code + PKCE)" with "Sessions": the console's SessionGate, /bff/user, the anti-forgery header, the 401 → sessionEnded(), the logout form, the polling and BroadcastChannel (ADR-1434, ADR-1438), and one paragraph
that the Playground keeps @ncode/auth as a public client on purpose. Full stack: the console resource on https://localhost:5005 and the mgmt build-watcher; drop 5173 and the CORS sentence about it. Gotchas: the run-once
ref example becomes the Playground's FlowExplorer callback completion (apps/playground/src/containers/FlowExplorer.tsx)
and StrictMode's example apps/playground/src/main.tsx.
Change: README.md local-run paragraph: the AppHost brings up the provider at https://localhost:5001, the
management console at https://localhost:5005, the flow explorer and the conformance dashboard. Host README's
development-credentials paragraph: the AppHost also writes mgmt-client.pem and mgmt-dpop.pem (the console's
client and DPoP keys) and hands the client key's public key set to the OP's developer seed; sign in at https://localhost:5005 as admin. AGENTS.md Layout: under Backend for frontend add the console:
"NCode.Identity.ManagementConsole composes the management console's BFF host, and NCode.Identity.ManagementConsole.Host is its reference host (ADR-1437)"; and mention Bff:KeyRings ([ADR-1439]…) beside AddIdentityBff(configuration).
[ ] 47. Rewrite the changelog entries the console's hosting reshapes
Files: CHANGELOG.md
Change: edit these ## [Unreleased] entries in place (find each by its quoted text):
"Development seeding signs in end to end": "provisions public (PKCE-only) single-page-application clients and a
deterministic administrator user" → "provisions public (PKCE-only) single-page-application clients, confidential
BFF clients and a deterministic administrator user"; add after the DeveloperSpaClientSeed parenthesis: "and
exactly the BFF clients under DeveloperInteractiveSeed:BffClients (DeveloperBffClientSeed: ClientId, Jwks
registered by value, RedirectUris, PostLogoutRedirectUris, BackchannelLogoutUri; private_key_jwt, PAR and
DPoP-bound tokens)"; "The reference host declares mgmt-spa and playground-spa (which requires consent)" → "The
reference host declares playground-spa (which requires consent) and the management console's BFF client mgmt-console, whose key set the AppHost supplies".
"IIdentityServerBuilder.AddFrontendBootstrap(configuration)": "publishes mgmt-spa with the dcr feature flag
and developer branding, playground-spa" → "publishes developer branding, playground-spa".
The AppHost entry: "and the mgmt, playground and conformance Vite dev servers" → "the management console host
(console) and its build-watcher (mgmt), and the playground and conformance Vite dev servers"; "and mgmt, playground and conformance on https://localhost:5173, 5174 and 5175, un-proxied" → "console on https://localhost:5005, and playground and conformance on https://localhost:5174 and 5175, un-proxied".
"The standalone Vite surfaces serve HTTPS outside Aspire too": "mgmt, playground or conformance" →
"playground or conformance"; "ports 5173–5175" → "ports 5174–5175".
"The management console signs in with authorization code + PKCE." → retitle "@ncode/auth holds the Playground's
public-client protocol helpers." and keep only the package sentence (PKCE S256 verifier and challenge, the
authorize URL, state validation, code exchange, refresh, the end-session URL and JWT claim decoding, injectable and
unit-tested, including the RFC 7636 test vector). Delete the entry "The management console renews its access token
silently and signs out through RP-Initiated Logout." in full.
"Every standalone frontend surface boots through one shared gate.": title → "Every frontend surface boots through
one shared gate."; "through createRuntimeConfigLoader({ surface })" → "through createRuntimeConfigLoader({ surface }), or the console through createBffRuntimeConfigLoader()".
"Every frontend workspace builds and tests from one Vite preset.": "the two serving models" → "the three serving
models (standalone, backend-served and BFF-served)"; "and the Management console's dev server pins port 5173,
which its redirect URI and CORS origin assume" → "and each standalone dev server pins the port its redirect URI and
CORS origin assume".
"The frontend bootstrap document never takes its issuer or API base from the request.": delete ", and the
standalone Vite dev servers proxy /api/ to the OP host".
Done when: $c = Get-Content -Raw CHANGELOG.md; if ($c -match 'mgmt-spa|5173|renews its access token silently|signs in with authorization code \+ PKCE') { exit 1 } else { exit 0 }
[ ] 48. Catalog the console's BFF host in the changelog
Files: CHANGELOG.md
Change: under ## [Unreleased] → ### Added, directly after the entry "One BFF serves several browser frontends.",
add three entries, each ending with its links:
"The management console is served by a BFF host of its own." — NCode.Identity.ManagementConsole
(AddManagementConsole(configuration), MapManagementConsole()) and NCode.Identity.ManagementConsole.Host: the
built apps/mgmt bundle under a per-response CSP nonce, /config/bootstrap.json, the mgmt frontend's /bff
endpoints, and /management/api/* forwarded to ManagementConsole:ManagementApiUrl with the user's DPoP-bound urn:ncode:management token; the confidential client mgmt-console of the control-plane tenant (private_key_jwt,
PAR, DPoP, back-channel logout); https://localhost:5005 under the AppHost, which generates the console's keys per
machine (issue [Serve the management console through the BFF instead of as a browser public client #158]…, [ADR-1437]…).
"A BFF loads its key rings from PEM files." — Bff:KeyRings:{name}:PemFiles, EC or RSA, kid = file name,
validated at startup ([ADR-1439]…).
"The management console reads its session from its BFF." — RuntimeConfig union and loadBffRuntimeConfig,
the session slice and fetchBffUser, SessionGate (poll, expiry and visibility probes, BroadcastChannel),
sign-out by the logout form, defineBffServedSurfaceConfig, readCspNonce in @ncode/ui ([ADR-1438]…).
Under ### Security add "The management console holds no token in the browser." — no access, refresh or ID
token reaches the console's script; its calls carry the __Host-Http-bff.mgmt cookie and the anti-forgery header,
and the console's origin is separate from the OP's login, consent and account pages (RFC 10017, [ADR-1437]…,
[ADR-1438]…). Every link uses the full https://github.com/NCodeGroup/Identity/issues/158 form and docs/decisions/<file>.md paths, as neighboring entries do.
Done when: if ((Select-String -Path CHANGELOG.md -Pattern 'served by a BFF host of its own' -Quiet) -and (Select-String -Path CHANGELOG.md -Pattern 'holds no token in the browser' -Quiet) -and (Select-String -Path CHANGELOG.md -Pattern 'Bff:KeyRings' -Quiet)) { exit 0 } else { exit 1 }
Review checklist
No file under src/frontend/apps/mgmt, packages/store or packages/auth holds or sends a token; @ncode/auth
exports only what apps/playground imports.
The console host's Program.cs is composition-only; every behavior lives in NCode.Identity.ManagementConsole and
is tested (tested-or-excluded).
The page CSP admits nothing but the response's nonce for scripts and styles, and form-action names exactly 'self'
and the authority's origin; the bootstrap document and the page are no-store; assets are immutable.
/management/api/* is limited to the mgmt frontend, needs the session and the anti-forgery header, and forwards
only to ManagementConsole:ManagementApiUrl; /api is not mapped on the console host.
The developer seed registers mgmt-console with PAR, DPoP-bound tokens, private_key_jwt, back-channel logout and the
key set by value, and the OP's Development overlay has no mgmt-spa, no 5173 origin and no default client id.
Bff:KeyRings reads key material through sensitive buffers, never logs a path's content, and fails startup for a
missing file.
SessionGate never navigates by itself; an end of session shows the sign-in screen in every tab.
Manual check after merge: run the AppHost, sign in at https://localhost:5005 as admin, open a tenant's clients,
sign out, and confirm the browser's storage and the network log carry no token.
Follow-ups
Publish and deploy the management console host: add a Dockerfile for src/NCode.Identity.ManagementConsole.Host that builds apps/mgmt into the image (as the OP's Dockerfile builds apps/auth), a CI image workflow, and a Helm chart component that mounts the mgmt-client/mgmt-dpop PEM keys from secrets and routes manage.<apex> (/api to the OP, the rest to the console), so the console has a production deployment.
Give BFF hosts the reverse-proxy trust and HSTS the OP host has: AddReverseProxy/UseReverseProxy and AddStrictTransportSecurity live in NCode.Identity.Server, so the console host behind an ingress has no trusted forwarded-headers list or HSTS; move them to a package a BFF host can reference and compose them in NCode.Identity.ManagementConsole.Host.
Share one single-page bundle reader between the interactive surface and the console: InteractiveBundleReader and InteractiveBundleAssetEndpointHandler (NCode.Identity.OpenId.Authentication.Interactive) and ManagementConsoleBundleReader/ManagementConsoleAssetHandler (NCode.Identity.ManagementConsole) duplicate the cached index read and the immutable asset serving; one general helper in NCode.AspNetCore removes the duplicate.
Plan update (step 15, D4): the console logs in without resource, so its login access token carries the OpenID audience that UserInfo requires (ADR-1027). The /management/api remote API declares Resource = urn:ncode:management, so the BFF obtains a separate, audience-restricted, DPoP-bound management token through the refresh-token grant with RFC 8707 resource, and no token is usable at both UserInfo and the management API. Step 15 drops Resources from the console host's configuration and proves a management call end to end.
Landed in #606 (900071a): the management console is served by its own BFF host as the confidential client mgmt-console and holds no token in the browser (ADR-1437, ADR-1438, ADR-1439). Follow-ups: #609, #610, #611, #612.
Activity
polewskm commentedon Nov 6, 2026
Plan snapshot. The working copy is
PLAN.mdin the plan's checkout; this comment is not updated as steps run. The plan is longer than one comment allows, so it is split in two: this comment holds steps 1–24, the next one steps 25–48 and the review checklist.Plan: feat(mgmt): serve the management console through its own BFF host (48 steps) — part 1 of 2
Plan: feat(mgmt): serve the management console through its own BFF host
Issue: #158
Refs: #123
Goal
The management console (
src/frontend/apps/mgmt) becomes a BFF frontend:NCode.Identity.ManagementConsole.Host, ahost of its own composed by the new
NCode.Identity.ManagementConsolepackage overAddIdentityBff, serves the builtconsole bundle, its
/bff/*endpoints and the forwarded management API on one origin, as the confidential clientmgmt-consoleof the control-plane tenant (private_key_jwt, PAR, DPoP, back-channel logout). The browser holds notoken: the console's
@ncode/authcustody, the Redux token store and theAuthorization: Bearerheader disappear, andthe console reads its session from
/bff/user. The Playground stays a public client.Context for the executor
dotnet tool restore, thenPush-Location src/frontend; npx -y pnpm@10.34.7 install --frozen-lockfile; Pop-Location.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet build <csproj> -m:2. Run one build or test at a time.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/<P>/<P>.csproj -m:2 --filter "FullyQualifiedName~<Class>" -- RunConfiguration.TreatNoTestsAsError=true<W>(typecheck, lint, tests with the coverage floors):Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=<W>; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter <W> exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }.csfile rundotnet csharpier format <file>; after editing a frontend file runnpx -y pnpm@10.34.7 exec prettier --write <file>fromsrc/frontend.src/NCode.Identity.ManagementConsole/, MCH =src/NCode.Identity.ManagementConsole.Host/,MCT =
tests/NCode.Identity.ManagementConsole.Tests/, IT =tests/NCode.Identity.OpenId.Integration.Tests/,FE =
src/frontend/, MG =src/frontend/apps/mgmt/..github/instructions/csharp-production.instructions.mdbefore editingsrc/**/*.cs, and.github/instructions/csharp-testing.instructions.mdbefore editingtests/**/*.cs(strict mocks from aMockRepository,Method_State_Expectednames,#regionper member, no Arrange/Act/Assert comments). Every publictype gets XML docs and
[PublicAPI]; every public member of a packable project is listed in itsPublicAPI.Unshipped.txt(the RS0016 code fix writes the line). One type per file, no nested types, file-scopednamespaces, primary constructors, the license header
// Copyright © NCode Group+// SPDX-License-Identifier: Apache-2.0.write "now", "no longer", "previously", "replaces", "renamed", "moved", "instead of the old" or a removed name.
src/frontend/README.md(stateless components in@ncode/ui, tests treat console warnings aserrors, coverage floors in each workspace's Vite/Vitest config). jsdom does not implement
HTMLFormElement.submit:a test that submits a form spies on it with
vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => {}).DefaultOutboundHttpForgeryGuard) never reaches loopback or private addresses, so on adeveloper machine the OP cannot fetch a
jwks_urior deliver a back-channel logout tohttps://localhost:5005.That is why the developer seed registers the console's public key by value.
MapBffRemoteApiforwards through its ownSocketsHttpHandler, which aTestServercannot receive: integrationtests prove the BFF guard on
/management/api/*(401/403) and never a forwarded call.WebApplicationFactoryhosts run inDevelopmentwithout their launch profile (seeIT/Infrastructure/HostApplicationFactory.cs).mgmt, its client id ismgmt-console, its key rings aremgmt-client(private_key_jwt) and
mgmt-dpop(DPoP), the console host listens onhttps://localhost:5005andhttp://localhost:5004.Decisions
NCode.Identity.ManagementConsole.Host,not in the OP host under
/mgmt/as the issue sketched. The most privileged UI never shares an origin with the OP'slogin, consent and account pages (path scoping is no boundary, ADR-1193; per-surface origins, ADR-1064); the
management API keeps one authentication model, sender-constrained bearer tokens of the control-plane tenant
(ADR-1191); the OP holds no console key and the console holds no OP key, database or signing credential; the console
is a client of the OP exactly as a third party's BFF is (design goal G3);
AddIdentityServerandAddIdentityBffnever share a service collection. The console bundle,
/bff/*and the forwarded API share the console host's origin.NCode.Identity.ManagementConsoleholds the console's server:AddManagementConsole(configuration)(composes
AddIdentityBff, bindsManagementConsolestrictly) returningIManagementConsoleBuilder(exposesIdentityBffBuilder), andMapManagementConsole(). The host'sProgram.csstays composition-only.mgmtis the fallback frontend(no selection rule; cookie
__Host-Http-bff.mgmt). The management API is forwarded at/management/api/*toManagementConsole:ManagementApiUrl(https://localhost:5001/apiin Development) with theusertoken requirementand resource
urn:ncode:management, so the console's API base is/management./apistays free on the console'sorigin, so the hosted platform's
manage.<apex>routes/apito the OP for machine callers and every other path tothe console host (amends ADR-1131).
mgmt-consoleis a confidential client of the control-plane tenant:private_key_jwt,require_pushed_authorization_requests,require_dpop_bound_access_tokens, grant typesauthorization_codeandrefresh_token, redirect URI<origin>/bff/callback, post-logout URI<origin>/, back-channel logout URI<origin>/bff/backchannel-logoutwithbackchannel_logout_session_required. It requestsprofile email offline_access read:clients(the BFF addsopenid) and resourceurn:ncode:management, loads UserInfo, andallow-lists
preferred_usernameandemailfor/bff/user. A deployment registersjwks_uri=<origin>/bff/jwks(ADR-1294); the developer seed registers the public key by value (amends ADR-1294 and ADR-1146).
Bff:KeyRings:{name}:PemFilesregisters a keyedISecretKeyCollectionProvidernamed
{name}that holds the EC or RSA private key of each PEM file (PKCS#8, SEC1 or PKCS#1), read through sensitivebuffers; a key's
kidis its file name without the extension; a relative path is resolved against the content root;startup validation requires at least one existing file per ring. A host that registers a keyed provider in code (a
KMS-held key, for example) keeps doing so; the configuration is the built-in source.
mgmt-client.pemandmgmt-dpop.pembesidethe other development secrets, hands their paths to the console (
Bff__KeyRings__…) and the client key's publicJWKS to the OP (
DeveloperInteractiveSeed__BffClients__0__Jwks).apps/mgmtis a build-watched bundle (vite build --watch) served by the console host; the Vite dev server on 5173,mgmt-spa, its CORS origin and the Vite/api/proxy are gone. On a developer machine the OP cannot deliver back-channel logout to the console (loopback guard).
/config/bootstrap.json:{ "apiBaseUrl": "/management", "bff": { "basePath": "/bff", "antiforgeryHeader": { "name": "X-CSRF", "value": "1" } }, "features": {…}, "branding": {…} }.@ncode/configmodelsRuntimeConfigas the unionof
OpenIdRuntimeConfig(kind: 'openid') andBffRuntimeConfig(kind: 'bff'), andloadBffRuntimeConfigfetchesno discovery.
@ncode/storehas no token slice: asessionslice (unknown | signed-in | signed-out,BffUser) fedby
fetchBffUser; the management base query sends the anti-forgery header with same-origin credentials, neverAuthorization, and dispatchessessionEnded()on401.@ncode/authkeeps the Playground's protocol helpers only:client.ts,renew.tsandgetSubjectare deleted.SessionGateprobes/bff/user, polls every 60 s, probes 1 s aftermin(idleExpiresAt, expiresAt), re-probes when a hidden tab becomes visible, and shares an end through theBroadcastChannelncode.mgmt.session. Any end returns the console to its sign-in screen ("Your session ended. Sign in to continue.")whose button navigates to
/bff/login?returnUrl=<path>; this refines ADR-1434's "navigate to /bff/login" so a logoutin one tab never races a login in another. Sign-out posts the logout form (
sid,returnUrl=/). The BFF's?error=shows on the sign-in screen as "Sign-in failed (
)."index.htmlfor every non-fileGET/HEADpath with a fresh nonce inplace of
__NCODE_CSP_NONCE__underscript-src 'nonce' 'strict-dynamic'; style-src 'nonce'; img-src 'self' [logo origin]; connect-src 'self'; form-action 'self' <authority origin>; base-uri 'none'; frame-ancestors 'none', no-store(
SetHtmlPageSecurity);/assets/*is immutable. A missing bundle answers503and never stops startup. The bundlebuilds with the new
defineBffServedSurfaceConfig(base/, nonce placeholder); emotion takes the nonce from<meta property="csp-nonce">throughreadCspNonce, which lives in@ncode/ui.Frontenddefault names no client and no feature flag; it serves the public-clientand read-only surfaces only.
console host with the real developer seed; a drift test ties the console's Development configuration to the OP's
seed entry; CI tests the console host against the built console bundle.
Out of scope
NCode.Identity.OpenId.Authentication.Interactive.fapi2profile formgmt-console, Valkey for the console under the AppHost, any change to themanagement API's authentication, the Playground's or conformance dashboard's behavior.
Steps
[ ] 1. Add the
Bff:KeyRingsoptions and their validationsrc/NCode.Identity.Bff/Options/BffKeyRingOptions.cs(new),src/NCode.Identity.Bff/Options/BffOptions.cs,src/NCode.Identity.Bff/Options/BffOptionsValidator.cs,src/NCode.Identity.Bff/PublicAPI.Unshipped.txt,tests/NCode.Identity.Bff.Tests/Options/BffOptionsValidatorTests.cs[PublicAPI] public sealed class BffKeyRingOptions { public List<string> PemFiles { get; set; } = []; }withXML docs (section
Bff:KeyRings:{name}; each entry is the path of a PEM-encoded EC or RSA private key, relative tothe content root or absolute; a key's id is its file name without the extension).
BffOptionsgainspublic Dictionary<string, BffKeyRingOptions> KeyRings { get; set; } = new(StringComparer.Ordinal);(sectionBff:KeyRings).BffOptionsValidator.Validatecalls a newValidateKeyRings(options.KeyRings, failures), which adds:Bff:KeyRings must be set.(null);Bff:KeyRings has a key ring without a name.(blank key);Bff:KeyRings:{name}:PemFiles must list at least one file.(null ring or empty list);Bff:KeyRings:{name}:PemFiles:{i} must not be blank.;Bff:KeyRings:{name}:PemFiles:{i} names '{path}', which does not exist.(resolve with
Path.GetFullPath(path, environment.ContentRootPath)andFile.Exists).Validate_WithExistingPemFiles_Succeeds(write a file inDirectory.CreateTempSubdirectory("bff-keys-"), delete it inDispose); set upContentRootPathon theenvironment mock where the validator reads it.
ValidateAntiforgery/ValidateTokensinBffOptionsValidator.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.Bff.Tests/NCode.Identity.Bff.Tests.csproj -m:2 --filter "FullyQualifiedName~BffOptionsValidatorTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 2. Register a keyed key-ring provider for every configured
Bff:KeyRingsentrysrc/NCode.Identity.Bff/Secrets/BffPemKeyRing.cs(new, internal),src/NCode.Identity.Bff/BffLibraryRegistration.cs,tests/NCode.Identity.Bff.Tests/Secrets/BffPemKeyRingTests.cs(new)internal static class BffPemKeyRingwithpublic static ISecretKeyCollectionProvider Load(IServiceProvider serviceProvider, string name): readIOptions<BffOptions>.Value.KeyRings[name].PemFiles,IHostEnvironment,ISecretKeyFactoryandISecretKeyCollectionProviderFactory; for each path (resolved as in step 1) read the bytes withFile.OpenHandle+RandomAccess.ReadintoBufferFactory.Rent(length, isSensitive: true, out Span<byte> bytes),decode with
SecureEncoding.UTF8into a second sensitive buffer of chars, and create the key withfactory.CreateEccPem(new KeyMetadata { KeyId = Path.GetFileNameWithoutExtension(path) }, chars); when that throwsCryptographicExceptionorArgumentException, useCreateRsaPemwith the same metadata; when both throw, throwInvalidOperationException($"The key ring '{name}' cannot read '{path}': it holds no PEM-encoded EC or RSA private key.").Return
collectionFactory.CreateStatic(keys). InAddBffConfiguration, after the options binding, for each child ofsection.GetSection("KeyRings").GetChildren()takevar name = child.Key;and callbuilder.ServiceCollection.AddKeyedSingleton<ISecretKeyCollectionProvider>(name, (sp, _) => BffPemKeyRing.Load(sp, name))(no null-forgiving operator: it fails the build outside tests);
update the method's XML summary to say it also registers the configured key rings.
ISecretKeyFactory/ISecretKeyCollectionProviderFactoryfromAddSecretsLibrary()in aServiceCollection, disposed withDisposeAsync): an EC PEM and an RSA PEM ring load with key ids equal to the filenames; a non-key file throws naming the ring;
AddBffConfigurationwithBff:KeyRings:ring:PemFiles:0resolvesGetRequiredKeyedService<ISecretKeyCollectionProvider>("ring"). Write PEMs withECDsa.ExportPkcs8PrivateKeyPem()andRSA.ExportRSAPrivateKeyPem()into a temp subdirectory.DevelopmentSecretFile.Readinsrc/NCode.Identity.OpenId.LocalDev/DevelopmentSecrets/DevelopmentSecretFile.cs;the keyed ring in
IT/Bff/BffTestHost.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.Bff.Tests/NCode.Identity.Bff.Tests.csproj -m:2 --filter "FullyQualifiedName~BffPemKeyRingTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 3. Scaffold the
NCode.Identity.ManagementConsolepackage and its test projectMC/NCode.Identity.ManagementConsole.csproj,MC/AssemblyInfo.cs,MC/PublicAPI.Shipped.txt,MC/PublicAPI.Unshipped.txt,MC/ManagementConsole.cs,MC/ManagementConsoleDefaults.cs,MCT/NCode.Identity.ManagementConsole.Tests.csproj,MCT/BaseTests.cs,MCT/ManagementConsoleDefaultsTests.cs,NCode.Identity.slnx(all new except the slnx)src/NCode.Identity.Bff.Server/NCode.Identity.Bff.Server.csproj(FrameworkReferenceMicrosoft.AspNetCore.App; ProjectReferencesNCode.Identity.Bff.Server,NCode.Identity.Bff,NCode.AspNetCore,NCode.Registration;JetBrains.AnnotationsPrivateAssets All; RootNamespaceNCode.Identity.ManagementConsole).AssemblyInfo.cs:InternalsVisibleTo("NCode.Identity.ManagementConsole.Tests")andInternalsVisibleTo("NCode.Identity.OpenId.Integration.Tests"). PublicAPI files start with#nullable enable.ManagementConsole:[PublicAPI] public readonly struct ManagementConsole : IRegistrationMarker<ManagementConsole>;(pattern
src/NCode.Identity.Bff.Server/IdentityBff.cs).ManagementConsoleDefaults(public static, documentedconsts):
ConfigurationSectionName = "ManagementConsole",FrontendName = "mgmt",ApiBasePath = "/management",ApiPath = "/management/api",ManagementApiResource = "urn:ncode:management",BootstrapPath = "/config/bootstrap.json",AssetsPath = "/assets",NoncePlaceholder = "__NCODE_CSP_NONCE__"(must equalCSP_NONCE_PLACEHOLDERof@ncode/vite-config).Test project like
tests/NCode.Identity.Bff.Server.Tests/NCode.Identity.Bff.Server.Tests.csprojreferencingMC; one testNoncePlaceholder_Always_MatchesTheFrontendPresetasserting"__NCODE_CSP_NONCE__". Add both projects:dotnet sln NCode.Identity.slnx add src/NCode.Identity.ManagementConsole --solution-folder srcanddotnet sln NCode.Identity.slnx add tests/NCode.Identity.ManagementConsole.Tests --solution-folder tests.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleDefaultsTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 4. Add the console's options and their validator
MC/ManagementConsoleOptions.cs,MC/ManagementConsoleBrandingOptions.cs,MC/ManagementConsoleOptionsValidator.cs(all new),
MC/PublicAPI.Unshipped.txt,MCT/ManagementConsoleOptionsValidatorTests.cs(new)[PublicAPI] public sealed class ManagementConsoleOptions(sectionManagementConsole):string? ManagementApiUrl(absolute URL of the OP's management API base, such ashttps://id.example.com/api),string? BundleRootPath(directory of the builtapps/mgmtbundle; relative to the content root or absolute),Dictionary<string, bool> Features { get; set; } = new(StringComparer.Ordinal),ManagementConsoleBrandingOptions Branding { get; set; } = new().ManagementConsoleBrandingOptions:DisplayName,LogoUrl,PrimaryColor,SecondaryColor,Mode(allstring?).internal sealed class ManagementConsoleOptionsValidator(IHostEnvironment environment) : IValidateOptions<ManagementConsoleOptions>fails with:ManagementConsole:ManagementApiUrl must be an absolute https URL without user info, query or fragment.(httpaccepted only when
environment.IsDevelopment());ManagementConsole:BundleRootPath must name the directory of the built apps/mgmt bundle.(blank);
ManagementConsole:Branding:LogoUrl must be an absolute https URL.;ManagementConsole:Branding:Mode must be 'light' or 'dark'.;ManagementConsole:Features has a feature without a name.httpcase and a valid set.src/NCode.Identity.Bff/Options/BffOptionsValidator.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleOptionsValidatorTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 5. Publish the console's bootstrap document
MC/ManagementConsoleBootstrapDocument.cs,MC/ManagementConsoleBffDocument.cs,MC/ManagementConsoleHeaderDocument.cs,MC/ManagementConsoleBrandingDocument.cs,MC/ManagementConsoleBootstrapHandler.cs(all new, internal),MCT/ManagementConsoleBootstrapHandlerTests.cs(new)[JsonPropertyName]camelCase names: documentapiBaseUrl,bff,features,branding(omitted when null); bffbasePath,antiforgeryHeader; headername,value; brandingdisplayName,logoUrl,primaryColor,secondaryColor,mode(each omitted when null or blank; the whole branding is null whenevery value is).
ManagementConsoleBootstrapDocument.Create(ManagementConsoleOptions options, BffOptions bff, PathString pathBase):apiBaseUrl=pathBase + ManagementConsoleDefaults.ApiBasePath;basePath=pathBase + bff.Frontends[ManagementConsoleDefaults.FrontendName].PathPrefix; header frombff.Antiforgery.internal sealed class ManagementConsoleBootstrapHandler(IOptions<ManagementConsoleOptions>, IOptions<BffOptions>)withpublic IResult Handle(HttpContext httpContext):httpContext.Response.SetNoStore()thenTypedResults.Json(document, JsonSerializerOptions.Web)./management,/bff,X-CSRF/1), a path base/console, a custom prefix andheader, branding omitted when empty, and the handler's
Cache-Control: no-store(execute theIResultagainst aDefaultHttpContextwith aMemoryStreambody and a service provider that hasAddLogging()).src/NCode.Identity.Server/Frontend/FrontendBootstrapDocument.csandFrontendBootstrapEndpointProvider.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleBootstrapHandlerTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 6. Read the console bundle and build each page's security
MC/ManagementConsoleBundleReader.cs,MC/ManagementConsoleBundleIndex.cs,MC/ManagementConsolePageSecurity.cs(all new, internal),
MCT/ManagementConsoleBundleReaderTests.cs,MCT/ManagementConsolePageSecurityTests.cs(new)internal sealed class ManagementConsoleBundleReader(IOptions<ManagementConsoleOptions> options, IHostEnvironment environment) : IDisposable— the root is
Path.GetFullPath(options.BundleRootPath, environment.ContentRootPath);ValueTask<string?> TryReadIndexAsync(CancellationToken)returnsindex.html's text or null when missing, cached in aManagementConsoleBundleIndex(DateTime LastWriteTimeUtc, long Length, string Html)record and re-read when the file'slast-write time or length changes;
IFileInfo? TryGetAsset(string assetPath)serves only theassetsdirectorythrough a lazily created
PhysicalFileProvider;public static string RenderIndex(string html, string nonce)returnshtml.Replace(ManagementConsoleDefaults.NoncePlaceholder, nonce, StringComparison.Ordinal).internal readonly record struct ManagementConsolePageSecurity(string Nonce, HtmlPageSecurityPolicy Policy)withpublic static ManagementConsolePageSecurity Create(Uri authority, string? logoUrl): a freshContentSecurityPolicySources.CreateNonce(); directivesscript-src(nonce,StrictDynamic),style-src(nonce),img-src(Self, plusContentSecurityPolicySources.Origin(logo)whenlogoUrlparses as an absolute URI),connect-src(Self),form-action(Self,ContentSecurityPolicySources.Origin(authority)); leavebase-uriand framing at their strict defaults.
assets, apath that leaves it,
RenderIndexreplacing every placeholder; security — the exact header valuedefault-src 'none'; script-src 'nonce-<n>' 'strict-dynamic'; style-src 'nonce-<n>'; img-src 'self'; connect-src 'self'; form-action 'self' https://id.example; base-uri 'none'; frame-ancestors 'none'and the logo origin case.
src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveBundleReader.cs,InteractivePageSecurity.cs; testsInteractiveBundleReaderTests.cs(temp subdirectory).$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleBundleReaderTests|FullyQualifiedName~ManagementConsolePageSecurityTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 7. Serve the console page and its assets
MC/ManagementConsolePageHandler.cs,MC/ManagementConsoleAssetHandler.cs(new, internal),MCT/ManagementConsolePageHandlerTests.cs,MCT/ManagementConsoleAssetHandlerTests.cs(new)ManagementConsolePageHandler(ManagementConsoleBundleReader reader, IOptions<BffOptions> bff, IOptions<ManagementConsoleOptions> options)with
public async Task<IResult> HandleAsync(HttpContext httpContext): no index →SetNoStore()andTypedResults.Problem(statusCode: 503, title: "The management console is not built.", detail: "Build apps/mgmt (pnpm --filter @ncode/app-mgmt build) and set ManagementConsole:BundleRootPath to its dist directory.");otherwise
security = ManagementConsolePageSecurity.Create(new Uri(bff.Value.Frontends["mgmt"].Authority), options.Value.Branding.LogoUrl),httpContext.Response.SetHtmlPageSecurity(security.Policy)andTypedResults.Text(ManagementConsoleBundleReader.RenderIndex(html, security.Nonce), "text/html; charset=utf-8").ManagementConsoleAssetHandler(ManagementConsoleBundleReader reader)withpublic IResult Handle(HttpContext httpContext):the
assetroute value →reader.TryGetAsset; unknown file or content type →404; otherwiseCache-Control: public, max-age=31536000, immutableandTypedResults.PhysicalFile(...)with last-modified, entity tagand range processing.
immutable cache header; 404 for a missing or unknown-type asset.
src/NCode.Identity.OpenId.Authentication.Interactive/InteractiveBundleAssetEndpointHandler.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsolePageHandlerTests|FullyQualifiedName~ManagementConsoleAssetHandlerTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 8. Compose the console in one call
MC/IManagementConsoleBuilder.cs,MC/ManagementConsoleBuilder.cs,MC/DefaultRegistration.cs,MC/ManagementConsoleFrontendValidator.cs(all new),MC/PublicAPI.Unshipped.txt,MCT/DefaultRegistrationTests.cs(new)[PublicAPI] public interface IManagementConsoleBuilder : IServiceBuilder<ManagementConsole> { IIdentityBffBuilder IdentityBffBuilder { get; } }.internal sealed class ManagementConsoleBuilder : ServiceBuilder<ManagementConsole>, IManagementConsoleBuilder— itsconstructor
(IServiceCollection serviceCollection, IConfiguration configuration)setsIdentityBffBuilder = serviceCollection.AddIdentityBff(configuration), bindsManagementConsoleOptionsstrictly fromManagementConsoleDefaults.ConfigurationSectionNamewithValidateOnStart(),TryAddEnumerablesIValidateOptions<ManagementConsoleOptions>→ManagementConsoleOptionsValidatorandIValidateOptions<BffOptions>→
ManagementConsoleFrontendValidator, and adds singletonsManagementConsoleBundleReader,ManagementConsoleBootstrapHandler,ManagementConsolePageHandler,ManagementConsoleAssetHandler.ManagementConsoleFrontendValidatorfails withBff:Frontends must configure the 'mgmt' frontend that serves the management console.[PublicAPI] public static class DefaultRegistrationwithextension(IServiceCollection serviceCollection)→public IManagementConsoleBuilder AddManagementConsole(IConfiguration configuration)(null guards).validator's failure and success.
src/NCode.Identity.Bff.Server/DefaultRegistration.cs,IdentityBffBuilder.cs,IIdentityBffBuilder.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~DefaultRegistrationTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 9. Map the console's endpoints
MC/ManagementConsoleEndpointRouteBuilderExtensions.cs(new),MC/PublicAPI.Unshipped.txt[PublicAPI] public static class ManagementConsoleEndpointRouteBuilderExtensionswithextension(IEndpointRouteBuilder endpoints)→public RouteGroupBuilder MapManagementConsole()that, in order:calls
endpoints.MapBffEndpoints()andendpoints.MapBffRemoteApis(); mapsendpoints.MapBffRemoteApi(ManagementConsoleDefaults.ApiPath, managementApiUrl, api => { api.TokenRequirement = BffTokenRequirementNames.User; api.Resource = ManagementConsoleDefaults.ManagementApiResource; }).RequireBffFrontend(ManagementConsoleDefaults.FrontendName)where
managementApiUrlisIOptions<ManagementConsoleOptions>.Value.ManagementApiUrlor, when it is null,throw new InvalidOperationException("ManagementConsole:ManagementApiUrl must be set.")(no null-forgiving operator);creates
group = endpoints.MapGroup(string.Empty).AllowAnonymous(); maps on itGET BootstrapPath→ManagementConsoleBootstrapHandler.Handle(nameManagementConsole.Bootstrap),GET|HEAD /assets/{**asset}→ManagementConsoleAssetHandler.Handle(nameManagementConsole.Asset), andgroup.MapFallback("{*path:nonfile}", …PageHandler.HandleAsync).WithMetadata(new HttpMethodMetadata([HttpMethods.Get, HttpMethods.Head]))(name
ManagementConsole.Page); excludes the group from the API description; returns the group. Resolve the handlersfrom
context.RequestServicesinside static lambdas, asMapBffEndpointsdoes. XML docs say what each route serves.src/NCode.Identity.Bff/Endpoints/BffEndpointRouteBuilderExtensions.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet build src/NCode.Identity.ManagementConsole/NCode.Identity.ManagementConsole.csproj -m:2[ ] 10. Add the console's reference host
MCH/NCode.Identity.ManagementConsole.Host.csproj,MCH/Program.cs,MCH/ManagementConsoleHostMarker.cs,MCH/Properties/AssemblyInfo.cs,MCH/Properties/launchSettings.json,MCH/appsettings.json,MCH/appsettings.Development.json(all new),NCode.Identity.slnxMicrosoft.NET.Sdk.Web, net10.0,IsPackablefalse, a newUserSecretsId; referencesMCandNCode.Identity.SharedState.Valkey.Program.cs(composition only, header comment like the OP host's): create thebuilder;
builder.Logging.ClearProviders().AddConsole().AddEventSourceLogger();var managementConsole = builder.Services.AddManagementConsole(builder.Configuration);managementConsole.IdentityBffBuilder.SharedStateLibraryBuilder.AddValkey(builder.Configuration);builder.Services.AddHealthChecks();build;UseHttpsRedirection(),UseBffSecurityHeaders(),UseBff(),UseAuthentication(),UseAuthorization(),MapManagementConsole(),/health/live(no checks) and/health/ready(tag
HealthCheckTags.Readiness) exactly assrc/NCode.Identity.OpenId.Host/Program.cs;await app.RunAsync().Marker like
HostApiMarker.cs;AssemblyInfo.csgrantsNCode.Identity.OpenId.Integration.Tests. Launch profileNCode.Identity.ManagementConsole.Host: Project,ASPNETCORE_ENVIRONMENT=Development,DOTNET_ReadyToRun=0,applicationUrlhttps://localhost:5005;http://localhost:5004.appsettings.json: Logging as the OP host, andBff:Frontends:mgmt=ClientIdmgmt-console,ClientAuthentication{ Method: private_key_jwt, KeyRing: mgmt-client },Scopes[profile, email, offline_access, read:clients],Resources[urn:ncode:management],ReturnUrls["/"],UserClaims[preferred_username, email],UserInfo{ Load: true },DPoP{ Mode: required, KeyRing: mgmt-dpop },Jwks{ Enabled: true }.appsettings.Development.json:Bff:Frontends:mgmtAuthorityhttps://localhost:5001,PublicOriginhttps://localhost:5005;ManagementConsoleManagementApiUrlhttps://localhost:5001/api,BundleRootPath../frontend/apps/mgmt/dist,Features{ dcr: true },Branding{ DisplayName: "NCode.Identity (dev)", Mode: light }.Run
dotnet sln NCode.Identity.slnx add src/NCode.Identity.ManagementConsole.Host --solution-folder src.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet build src/NCode.Identity.ManagementConsole.Host/NCode.Identity.ManagementConsole.Host.csproj -m:2[ ] 11. Prove the console host over HTTP
IT/NCode.Identity.OpenId.Integration.Tests.csproj,IT/Infrastructure/ManagementConsoleApplicationFactory.cs(new),IT/ManagementConsole/ManagementConsoleHostTests.cs(new)MCH.ManagementConsoleApplicationFactory : WebApplicationFactory<ManagementConsoleHostMarker>with constructor arguments
HostApplicationFactory? provider = null, ECDsa? clientKey = null: Development environment; configurationoverlay
Bff:Frontends:mgmt:Authority=HostApplicationFactory.TestServerIssuer,ManagementConsole:ManagementApiUrl=
http://localhost/api,ManagementConsole:BundleRootPath= a temp subdirectory holding anindex.htmlwith<meta property="csp-nonce" nonce="__NCODE_CSP_NONCE__">, an empty<div id="root"></div>and a nonce'd modulescript, plus
assets/index-test.js; test services: keyedISecretKeyCollectionProvidermgmt-clientandmgmt-dpopfrom P-256ECDsakeys (CreateEcc(new KeyMetadata { KeyId = "mgmt-client" }, key); the client key isclientKeywhen given, otherwise one the factory creates and owns), and, whenprovideris set,AddHttpClient(OpenIdClientDefaults.HttpClientName).ConfigurePrimaryHttpMessageHandler(() => provider.Server.CreateHandler()).CreateBrowserClient()returns a client with basehttps://localhost:5005/, no redirects, no cookie handling.Dispose the keys and the directory in
DisposeAsync.GetPage_AtAConsoleRoute_ServesTheBundleUnderAFreshNonce(/tenants/acme/clients: 200, nonce in body equalsthe CSP's, no placeholder,
frame-ancestors 'none',form-action 'self' http://localhost,no-store); asset →immutable;
GetBootstrap_Always_PublishesTheBffDocument(/management,/bff,X-CSRF,1, branding display name);/bff/userand/management/api/callerwithX-CSRF: 1and no cookie → 401;/management/api/callerwithout theheader → 403.
IT/Infrastructure/HostApplicationFactory.cs,IT/Bff/BffTestHost.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleHostTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 12. Declare confidential BFF clients in the developer seed's options
src/NCode.Identity.Server/DevelopmentEnvironment/DeveloperBffClientSeed.cs(new),src/NCode.Identity.Server/DevelopmentEnvironment/DeveloperInteractiveSeedOptions.cs,src/NCode.Identity.Server/DevelopmentEnvironment/DeveloperInteractiveSeedRegistration.cs,src/NCode.Identity.Server/PublicAPI.Unshipped.txt,tests/NCode.Identity.Server.Tests/DevelopmentEnvironment/DeveloperInteractiveSeedOptionsTests.cs[PublicAPI] public sealed class DeveloperBffClientSeed:string? ClientId,string? Jwks(JSON text of thepublic JSON Web Key Set registered by value; supplied from outside the repository, such as the AppHost),
string[] RedirectUris = [],string[] PostLogoutRedirectUris = [],string? BackchannelLogoutUri,bool ShouldSeed => !string.IsNullOrEmpty(ClientId) && !string.IsNullOrEmpty(Jwks) && RedirectUris.Length > 0.Its XML summary: a development-only confidential BFF client (private_key_jwt, PAR, DPoP-bound tokens, back-channel
logout) whose keys are registered by value because the server's outbound guard never reaches a loopback
jwks_uri.DeveloperInteractiveSeedOptionsgainspublic IList<DeveloperBffClientSeed> BffClients { get; set; } = [];(docs asSpaClients), and its class summary names confidential BFF clients beside the public SPA clients.AddDeveloperInteractiveSeed'shasSeedDataalso countsoptions.BffClients.Count > 0; its XML docs and theregistration's class summary say it seeds the BFF clients too.
BffClients_ByDefault_IsEmpty,BffClients_WhenBoundFromConfiguration_HoldsExactlyTheConfiguredClients, andShouldSeedfalse without a client id, a key set or a redirect URI, true with all three.DeveloperSpaClientSeedand its tests.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.Server.Tests/NCode.Identity.Server.Tests.csproj -m:2 --filter "FullyQualifiedName~DeveloperInteractiveSeedOptionsTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 13. Seed the configured BFF clients
src/NCode.Identity.Server/DevelopmentEnvironment/DeveloperInteractiveSeedHandler.cs,tests/NCode.Identity.Server.Tests/DevelopmentEnvironment/DeveloperInteractiveSeedHandlerTests.csIClientKeyImporter clientKeyImporterandTimeProvider timeProvider.HandleAsyncseeds eachBffClientsentry whoseShouldSeedis true, after the SPA clients, through a newSeedBffClientAsync: skip an existing client; settingsredirect_uris,post_logout_redirect_uris(when any),backchannel_logout_uri+backchannel_logout_session_required= true (when set),token_endpoint_auth_method=private_key_jwt,grant_types_supported=[authorization_code, refresh_token],require_pushed_authorization_requests= true,require_dpop_bound_access_tokens= true (use theOpenIdSettingNames/OpenIdProtocolconstants);SeededClientValidator.EnsureCanBeSeededAsync("developer BFF client", …);secrets = the keys
clientKeyImporter.TryImportRegisteredKeySet(JsonElement.Parse(jwks), timeProvider.GetUtcNow(), out keys, out error)imports; an unparsable or refused key set throws
InvalidOperationExceptionnaming the client, as the conformance seed does.Update the class summary to name the BFF clients.
naming the client; an entry that should not seed is skipped. Follow the file's strict-mock scaffold.
SeedClientAsync/ImportKeysinsrc/NCode.Identity.Server/ConformanceEnvironment/ConformanceSeedHandler.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.Server.Tests/NCode.Identity.Server.Tests.csproj -m:2 --filter "FullyQualifiedName~DeveloperInteractiveSeedHandlerTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 14. Point the OP's Development overlay at the console host
src/NCode.Identity.OpenId.Host/appsettings.Development.json,IT/Endpoints/FrontChannelCorsTests.cs,src/NCode.Identity.Server/Frontend/FrontendBootstrapOptions.cs,src/NCode.Identity.Server/Frontend/FrontendIssuerResolver.csFrontend:ClientIdandFrontend:Features, themgmt-spaentry ofDeveloperInteractiveSeed:SpaClientsandhttps://localhost:5173fromCors:AllowedOrigins; addDeveloperInteractiveSeed:BffClients=[{ "ClientId": "mgmt-console", "RedirectUris": ["https://localhost:5005/bff/callback"], "PostLogoutRedirectUris": ["https://localhost:5005/"], "BackchannelLogoutUri": "https://localhost:5005/bff/backchannel-logout" }](no
Jwks: the AppHost supplies it). InFrontChannelCorsTestssetAllowedOrigintohttps://localhost:5174. Inthe XML docs of
FrontendBootstrapOptions(Issuer,Surfaces) andFrontendIssuerResolver, replace "served to theManagement console" / "which serves the Management console" with wording that the default document serves every
surface that has no
Surfacesentry, and that the root tenant serves the surfaces under the dynamic strategies.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~DeveloperSpaClientSeedingTests|FullyQualifiedName~FrontChannelCorsTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 15. Log in through the OP into the console host, end to end
IT/Infrastructure/ManagementConsoleSeedingHostApplicationFactory.cs(new),IT/ManagementConsole/ManagementConsoleEndToEndTests.cs(new)ManagementConsoleSeedingHostApplicationFactory(string consoleClientJwks) : HostApplicationFactorywithSeedsDeveloperClients => trueand a configuration overlayDeveloperInteractiveSeed:BffClients:0:Jwks=consoleClientJwks(patternConfiguredHostApplicationFactory.cs). The test creates a P-256ECDsaclient key, theJWKS
{"keys":[{"kty":"EC","crv":"P-256","x":…,"y":…,"kid":"mgmt-client","use":"sig","alg":"ES256"}]}fromkey.ExportParameters(false)(Base64Url), the OP factory with that JWKS, and the console factory withprovider= the OP factory andclientKey= the key; it seeds a local account (SeedLocalAccountAsync), signs the browser in at the OP(
InteractiveLogin.SignInAsync), then:GET /bff/login?returnUrl=%2Fteams→ 302 to the OP; the authorize request withthe OP session cookie → 302 to
https://localhost:5005/bff/callback…; the callback → 302 to/teamswith a__Host-Http-bff.mgmtcookie;GET /bff/userwith the cookie andX-CSRF: 1→ 200 whose JSON hassubandlogoutSid.Keep cookies in a dictionary as
BffTestHost.SendAsyncdoes.Login_ThenUserAndApi_SucceedinIT/Bff/BffEndToEndTests.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleEndToEndTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 16. Keep the console's Development configuration and the OP's seed in step
IT/ManagementConsole/ManagementConsoleDevelopmentOverlayTests.cs(new)DeveloperSeedingHostApplicationFactoryclass fixture and aManagementConsoleApplicationFactory(step 11; it overlays only the authority, the API URL and the bundle root, so
ClientId,PublicOriginandPathPrefixcome from the console host's ownappsettings*.json). Read the console'sIOptions<BffOptions>.Value.Frontends["mgmt"]and the OP'sDeveloperInteractiveSeedOptions. Assert that the OP'sBffClientsholds one entry whoseClientIdequals the console'sClientId, whoseRedirectUriscontainPublicOrigin + PathPrefix + "/callback", whosePostLogoutRedirectUriscontainPublicOrigin + "/", and whoseBackchannelLogoutUriequalsPublicOrigin + PathPrefix + "/backchannel-logout".IT/Endpoints/DeveloperSpaClientSeedingTests.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.Integration.Tests/NCode.Identity.OpenId.Integration.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleDevelopmentOverlayTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 17. Generate the console's development keys per machine
src/NCode.Identity.OpenId.LocalDev/DevelopmentSecrets/DevelopmentKeyFile.cs(new),tests/NCode.Identity.OpenId.LocalDev.Tests/DevelopmentSecrets/DevelopmentKeyFileTests.cs(new)internal static class DevelopmentKeyFilewithinternal static string EnsureEcPrivateKey(string directory, string fileName)— the same create-once contract as
DevelopmentSecretFile.Ensure(temporary file,CreateNew, user-only Unix mode,DevelopmentSecretFile.MoveIntoPlace, an empty file is replaced), writing a P-256 key as PKCS#8 PEM throughECDsa.TryExportPkcs8PrivateKeyPeminto a rented sensitive buffer; andinternal static string ReadPublicJwks(string path)— the JSON text
{"keys":[{"kty":"EC","crv":"P-256","x":…,"y":…,"kid":<file name without extension>,"use":"sig","alg":"ES256"}]}written with
Utf8JsonWriterand Base64Url coordinates. XML docs say why: the OP registers the console's public keyby value because its outbound guard never fetches a loopback
jwks_uri.coordinates match the PEM's public key.
DevelopmentSecretFile.csandDevelopmentSecretFileTests.cs.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet test tests/NCode.Identity.OpenId.LocalDev.Tests/NCode.Identity.OpenId.LocalDev.Tests.csproj -m:2 --filter "FullyQualifiedName~DevelopmentKeyFileTests" -- RunConfiguration.TreatNoTestsAsError=true[ ] 18. Run the console host from the AppHost
src/NCode.Identity.OpenId.LocalDev/Program.cs,src/NCode.Identity.OpenId.LocalDev/NCode.Identity.OpenId.LocalDev.csprojMCH. After the admin password:consoleClientKeyPath = DevelopmentKeyFile.EnsureEcPrivateKey(developmentSecretsDirectory, "mgmt-client.pem"),consoleDPoPKeyPath = … "mgmt-dpop.pem",consoleClientJwks = DevelopmentKeyFile.ReadPublicJwks(consoleClientKeyPath);apigets.WithEnvironment("DeveloperInteractiveSeed__BffClients__0__Jwks", consoleClientJwks). Replace theAddViteApp("mgmt", …)block withbuilder.AddJavaScriptApp("mgmt", "../frontend/apps/mgmt").WithPnpm(install: false);and add
builder.AddProject<Projects.NCode_Identity_ManagementConsole_Host>("console").WithHttpHealthCheck("/health/ready").WithEnvironment("Bff__KeyRings__mgmt-client__PemFiles__0", consoleClientKeyPath).WithEnvironment("Bff__KeyRings__mgmt-dpop__PemFiles__0", consoleDPoPKeyPath).WaitFor(api).WithExternalHttpEndpoints();.Rewrite the comments:
mgmtis a build-watcher whosedistthe console host serves (likeauth);consoleis theconsole's BFF host on
https://localhost:5005, a confidential client of theapiwhose key the AppHost generates;the
playgroundcomment describes its own pins without namingmgmt; the header comment of the key files sits beside theroot-key comment. Keep the file declarative.
$env:MSBUILDDISABLENODEREUSE = '1'; dotnet build src/NCode.Identity.OpenId.LocalDev/NCode.Identity.OpenId.LocalDev.csproj -m:2[ ] 19. Add the BFF-served surface preset to
@ncode/vite-configFE/packages/vite-config/src/preset.ts,FE/packages/vite-config/src/preset.test.ts,FE/packages/vite-config/package.jsonexport type BffServedSurfaceConfigOptions = { readonly coverage: CoverageFloors };andexport function defineBffServedSurfaceConfig({ coverage }: BffServedSurfaceConfigOptions): ViteUserConfigreturning{ base: '/', html: { cspNonce: CSP_NONCE_PLACEHOLDER }, plugins: [react(), failOnOversizedChunks()], build: buildOptions(), test: testGates({ environment: 'jsdom', coverage }) },documented as the configuration of a surface its own BFF host serves at the root of its origin, under the page's CSP
nonce, with no dev server (its
devscript rebuildsdist). DeleteAPI_PATHand the/api/proxy entry fromdefineStandaloneSurfaceConfigand its doc sentence about the management API (no standalone surface calls it); theCSP_NONCE_PLACEHOLDERdoc names the console host'sManagementConsoleDefaults.NoncePlaceholderbesideInteractiveBundleShell.NoncePlaceholder.package.jsondescription: "…the test gates and the standalone,backend-served and BFF-served surface presets."
/config/bootstrap.json; adescribe('defineBffServedSurfaceConfig')blockmirroring the backend-served one (base
/, nonce placeholder, noserver, jsdom test gates).Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/vite-config; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/vite-config exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }[ ] 20. Model the BFF runtime configuration in
@ncode/configFE/packages/config/src/types.ts,FE/packages/config/src/parse.ts,FE/packages/config/src/parse.test.tstypes.ts: rename the resolved OpenID type toOpenIdRuntimeConfigand addreadonly kind: 'openid'; addAntiforgeryHeader = { readonly name: string; readonly value: string },BffConfig = { readonly basePath: string; readonly antiforgeryHeader: AntiforgeryHeader },BffBootstrapDocument = { readonly apiBaseUrl: string; readonly bff: BffConfig; readonly features?: …; readonly branding?: BrandingConfig },BffRuntimeConfig = { readonly kind: 'bff'; readonly apiBaseUrl: string; readonly bff: BffConfig; readonly features: Readonly<Record<string, boolean>>; readonly branding?: BrandingConfig }and
export type RuntimeConfig = OpenIdRuntimeConfig | BffRuntimeConfig;. Update the module andBootstrapDocumentdocs (an OpenID surface's document names the public client; a BFF-served surface's document names its BFF and no
token-related value).
parse.ts:mergeRuntimeConfigreturnsOpenIdRuntimeConfigwithkind: 'openid'; addparseBffBootstrapDocument(raw: unknown): BffBootstrapDocument(object required;apiBaseUrlthroughrequireNonEmpty+normalizeBaseUrl;bffobject required — errorBootstrap document is missing required field 'bff'.;bff.basePathnon-empty, starting with/, normalized;bff.antiforgeryHeader.nameand.valuenon-empty, with fieldnames
bff.basePath,bff.antiforgeryHeader.name,bff.antiforgeryHeader.value; features and branding asparseBootstrapDocumentreads them) andtoBffRuntimeConfig(document: BffBootstrapDocument): BffRuntimeConfig.kind: 'openid'in every merged expectation; BFF parsing of a full document, each missing field, a base pathwithout a leading slash, and
toBffRuntimeConfigdefaults (features: {}, no branding).Push-Location src/frontend/packages/config; npx -y pnpm@10.34.7 exec vitest run src/parse.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 21. Load a BFF-served surface's configuration without discovery
FE/packages/config/src/loader.ts,FE/packages/config/src/loader.test.ts,FE/packages/config/src/index.tsloadRuntimeConfigreturnsPromise<OpenIdRuntimeConfig>. AddloadBffRuntimeConfig(options: Omit<LoadRuntimeConfigOptions, 'surface'> = {}): Promise<BffRuntimeConfig>— the samefetch and origin resolution, fetching only
${origin}${BOOTSTRAP_PATH}(nosurface, no discovery), thentoBffRuntimeConfig(parseBffBootstrapDocument(raw)); andcreateBffRuntimeConfigLoader(options: Omit<LoadRuntimeConfigOptions, 'surface' | 'signal'> = {}): RuntimeConfigLoader.RuntimeConfigLoaderstays(signal: AbortSignal) => Promise<RuntimeConfig>(the union). Export the new types andfunctions from
index.ts(AntiforgeryHeader,BffBootstrapDocument,BffConfig,BffRuntimeConfig,OpenIdRuntimeConfig,parseBffBootstrapDocument,toBffRuntimeConfig,loadBffRuntimeConfig,createBffRuntimeConfigLoader).failure, and the bound loader passes its signal.
Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/config; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/config exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }[ ] 22. Narrow the Playground to the OpenID configuration
FE/apps/playground/src/App.tsx,FE/apps/playground/src/flow.ts,FE/apps/playground/src/containers/FlowExplorer.tsx,FE/apps/playground/src/App.test.tsx,FE/apps/playground/src/flow.test.ts,FE/apps/playground/src/containers/FlowExplorer.test.tsxRuntimeConfigtype in these files becomesOpenIdRuntimeConfig;Apprenders nothing unlessruntime?.kind === 'openid'; every runtime literal in the tests gainskind: 'openid';App.test.tsxadds a casewhere a BFF runtime renders nothing.
Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/app-playground; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/app-playground exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }[ ] 23. Narrow the conformance dashboard and the boot gate's tests
FE/apps/conformance/src/App.tsx,FE/apps/conformance/src/containers/ConformanceDashboard.tsx,FE/apps/conformance/src/App.test.tsx,FE/apps/conformance/src/containers/ConformanceDashboard.test.tsx,FE/packages/shell/src/BootstrapGate.test.tsxOpenIdRuntimeConfig,runtime?.kind === 'openid',kind: 'openid'literals, a BFF-runtime case in
App.test.tsx); the gate's test runtime literal gainskind: 'openid', and theBootstrapGatedoc comment ("there is no API base URL or issuer") reads "no API base URL" only.Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/app-conformance --filter=@ncode/shell; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/app-conformance --filter @ncode/shell exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }[ ] 24. Share the CSP nonce reader through
@ncode/uiFE/packages/ui/src/cspNonce.ts(new; the content ofFE/apps/auth/src/cspNonce.ts),FE/packages/ui/src/cspNonce.test.ts(new; the content ofFE/apps/auth/src/cspNonce.test.ts),FE/packages/ui/src/index.ts,FE/apps/auth/src/mount.tsx,FE/apps/auth/src/cspNonce.tsandFE/apps/auth/src/cspNonce.test.ts(both deleted)git mv; exportCSP_NONCE_SELECTORandreadCspNoncefrom@ncode/ui's index;mount.tsximportsreadCspNoncefrom@ncode/ui. The doc comment says the host that serves a bundle writes thenonce, so it holds for the OP and the console host.
Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/ui --filter=@ncode/app-auth; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/ui --filter @ncode/app-auth exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }polewskm commentedon Nov 6, 2026
Plan snapshot, part 2 of 2 (steps 25–48, the review checklist and the follow-ups). The working copy is
PLAN.mdin the plan's checkout; this comment is not updated as steps run.Plan: feat(mgmt): serve the management console through its own BFF host (48 steps) — part 2 of 2
[ ] 25. Hold the BFF session in
@ncode/storeFE/packages/store/src/sessionSlice.ts,FE/packages/store/src/sessionSlice.test.ts,FE/packages/store/src/bffSession.ts,FE/packages/store/src/bffSession.test.ts(all new)sessionSlice.ts:BffSessionLifetime = { readonly expiresAt: number; readonly idleExpiresAt: number }(epoch seconds);
BffUser = { readonly sub: string; readonly name?: string; readonly claims: Readonly<Record<string, unknown>>; readonly session: BffSessionLifetime; readonly logoutUrl: string; readonly logoutSid: string };SessionStatus = 'unknown' | 'signed-in' | 'signed-out';SessionState = { readonly status: SessionStatus; readonly user: BffUser | null };sessionSlice(namesession, initial{ status: 'unknown', user: null }) withsessionStarted(user)→{ status: 'signed-in', user }andsessionEnded()→{ status: 'signed-out', user: null }(return fresh state, asconfigSlicedoes).bffSession.ts:parseBffUser(raw: unknown): BffUser— throwsError('The session probe answered with a malformed user.')unlesssub,logoutUrl,logoutSidare non-emptystrings,
session.expiresAtandsession.idleExpiresAtare numbers andclaimsis an object (namekept only whena non-empty string);
fetchBffUser(runtime: BffRuntimeConfig, fetchFn: typeof fetch = fetch): Promise<BffUser | null>—
GET ${runtime.bff.basePath}/userwith{ credentials: 'same-origin', cache: 'no-store', headers: { accept: 'application/json', [name]: value } }from
runtime.bff.antiforgeryHeader;401→null;ok→parseBffUser(await response.json()); otherwise throwError(\The session probe failed (HTTP ${status}).`)`. Document that the probe never slides the session's idle window (ADR-1434).Push-Location src/frontend/packages/store; npx -y pnpm@10.34.7 exec vitest run src/sessionSlice.test.ts src/bffSession.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 26. Call the management API with the session cookie, never a token
FE/packages/store/src/baseQuery.ts,FE/packages/store/src/store.ts,FE/packages/store/src/index.ts,FE/packages/store/src/baseQuery.test.ts,FE/packages/store/src/store.test.ts,FE/packages/store/src/configSlice.test.ts,FE/packages/store/src/authSlice.tsandFE/packages/store/src/authSlice.test.ts(both deleted)ApiRootState = { readonly config: { readonly runtime: RuntimeConfig | null } }.dynamicBaseQuery: nullruntime → the existing
CUSTOM_ERROR;runtime.kind !== 'bff'→CUSTOM_ERROR'The management API is called only from a surface its BFF serves.';otherwise
fetchBaseQuery({ baseUrl: \${runtime.apiBaseUrl}/`, credentials: 'same-origin', prepareHeaders: (headers) => { headers.set(name, value); return headers; } })with the anti-forgery header, and after the callif (result.error?.status === 401) api.dispatch(sessionEnded());. Rewrite its doc comments (the cookie and the header authenticate the call; the browser holds no token). The root reducer holdssession: sessionSlice.reducerin place of the auth slice;index.tsexportssessionSlice,sessionStarted,sessionEnded,SessionState,SessionStatus,BffUser,BffSessionLifetime,parseBffUser,fetchBffUserand no auth symbol. Delete the auth slice files (git rm`).CUSTOM_ERRORfor an OpenID runtime, base URL resolved at requesttime, the anti-forgery header sent and no
authorizationheader,credentialssame-origin, a401ends the session,a
500does not; store — initialsessionstate and preloaded session;configSlice.test.tsliterals gainkind.Push-Location src/frontend/packages/store; npx -y pnpm@10.34.7 exec vitest run src/baseQuery.test.ts src/store.test.ts src/configSlice.test.ts src/sessionSlice.test.ts src/bffSession.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 27. Rebase the management API's tests on the BFF runtime
FE/packages/store/src/managementApi.test.ts,FE/packages/store/src/apiError.test.tsconst runtime: BffRuntimeConfig = { kind: 'bff', apiBaseUrl: 'https://api.test', bff: { basePath: '/bff', antiforgeryHeader: { name: 'X-CSRF', value: '1' } }, features: {} };;createStore()preloads onlyconfig;CapturedRequest.authorizationbecomesantiforgeryread fromrequest.headers.get('x-csrf'), and every expectationauthorization: 'Bearer token-123'becomesantiforgery: '1'.apiError.test.tschanges only where it builds a runtime or an auth state.Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/store; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/store exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }[ ] 28. Keep only the Playground's protocol helpers in
@ncode/authFE/packages/auth/src/client.ts,FE/packages/auth/src/client.test.ts,FE/packages/auth/src/renew.ts,FE/packages/auth/src/renew.test.ts(all deleted),FE/packages/auth/src/jwt.ts,FE/packages/auth/src/jwt.test.ts,FE/packages/auth/src/index.ts,FE/packages/auth/package.json,FE/packages/auth/src/flow.test.tsgit rmthe client and renewal modules and their tests; deletegetSubjectand its tests fromjwt.ts;in
flow.test.tsthe sample client idmgmt-spabecomesplayground-spa;index.tsexports onlypkce.js,flow.jsanddecodeJwtClaims;package.jsondescription: "Framework-free OAuth2.0 authorization-code + PKCE protocol helpers for the Playground's public client." Only
@ncode/app-mgmtimportsthe deleted symbols, and steps 29–31 remove those imports.
Push-Location src/frontend; npx -y pnpm@10.34.7 exec turbo run typecheck lint --filter=@ncode/auth --filter=@ncode/app-playground; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 --filter @ncode/auth exec vitest run --coverage; $b = $LASTEXITCODE; Pop-Location; if ($a -or $b) { exit 1 } else { exit 0 }[ ] 29. Add the console's session helpers
MG/src/session.ts,MG/src/session.test.ts(new)SESSION_POLL_INTERVAL_MS = 60_000,SESSION_CHANNEL_NAME = 'ncode.mgmt.session',SESSION_ENDED_MESSAGE = 'ended',SESSION_ENDED_NOTICE = 'Your session ended. Sign in to continue.', and:loginUrl(runtime: BffRuntimeConfig, returnPath: string): string→${runtime.bff.basePath}/login?returnUrl=${encodeURIComponent(returnPath)};submitLogout(user: BffUser, doc: Document = document): void— appends todoc.bodya hidden<form method="post" action={user.logoutUrl}>with hidden inputssid=user.logoutSidandreturnUrl=/, andcalls
form.submit()(a form post is a top-level navigation that follows the redirect to the OP);readSignInError(search: string): string | undefined→ theerrorquery parameter, if any;signInErrorMessage(code: string): string→Sign-in failed (${code}).;nextProbeDelayMs(user: BffUser, now: number = Date.now()): number→Math.max(0, Math.min(user.session.idleExpiresAt, user.session.expiresAt) * 1000 - now) + 1000;openSessionChannel(onEnded: () => void): { close(): void }— whenglobalThis.BroadcastChannelexists, a channelnamed
SESSION_CHANNEL_NAMEthat callsonEndedforSESSION_ENDED_MESSAGE; otherwise a no-op;announceSessionEnded(): void— opens a channel, postsSESSION_ENDED_MESSAGE, closes it (no-op without the API).A header comment cites ADR-1434 and ADR-1438.
HTMLFormElement.prototype.submit), the channel witha fake
BroadcastChannelinstalled byvi.stubGlobal(message delivered, other message ignored, close) and without one.Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/session.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 30. Gate the console on its BFF session
MG/src/SessionGate.tsx,MG/src/SessionGate.test.tsx(new),MG/src/AuthGate.tsx,MG/src/AuthGate.test.tsx,MG/src/auth.ts,MG/src/auth.test.ts(four deleted withgit rm)export function SessionGate({ children }: { children: ReactNode }). Readsconfig.runtimeandsession.A runtime whose
kindis not'bff'renders<BootstrapScreen error="The management console must be served by its BFF host." />.On mount it probes (
fetchBffUser(runtime)→sessionStarted(user)orsessionEnded(); a thrown probe sets thelocal error
'Unable to check your session.'); results after unmount are ignored. The?error=code is read once(
useStateinitializer withreadSignInError(window.location.search)) and removed from the address bar withwindow.history.replaceState(null, '', window.location.pathname). Whilesigned-in: an interval probe everySESSION_POLL_INTERVAL_MS, a timeout probe afternextProbeDelayMs(user), avisibilitychangelistener that probeswhen
document.visibilityState === 'visible', andopenSessionChannel(...)whose callback marks a refendedByBroadcastand dispatchessessionEnded(); all cleaned up. A transition fromsigned-intosigned-outthat the channel did not cause calls
announceSessionEnded(); either way the gate remembers that a session ended.Render:
signed-in→ children;unknown→<SignInScreen isBusy …/>;signed-out→<SignInScreen …/>witherror= the sign-in error message, elseSESSION_ENDED_NOTICEafter an ended session, else the probe error; itsonSignInsets busy and callswindow.location.assign(loginUrl(runtime, window.location.pathname)). PassproductNamefrom the runtime's branding asAuthGatedoes.fetchstubbed withvi.stubGlobal,window.locationreplaced asApp.test.tsxdoes): busy while probing; children after a user; sign-in after a
401and the login navigation withreturnUrl;the
?error=message and its removal; the probe-failure message; the BFF-host error for an OpenID runtime; the poll,the expiry probe and the visibility probe; a broadcast ends the session with the notice and no re-announce; a
sessionEnded()dispatched by the store announces once.MG/src/AuthGate.tsx(structure,SignInScreenprops) andMG/src/AuthGate.test.tsx(setup).Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/SessionGate.test.tsx src/session.test.ts; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 31. Build the console as a BFF-served bundle
MG/package.json,MG/vite.config.ts,MG/src/main.tsx,FE/pnpm-lock.yamlpackage.json:"dev": "vite build --watch", delete thepreviewscript, delete the@ncode/authdependency, add
"@emotion/cache": "^11.14.0"(the range@ncode/app-authuses); description "NCode.IdentityManagement console (served by its BFF host)."
vite.config.ts:defineBffServedSurfaceConfig({ coverage: { statements: 97, branches: 95, functions: 100, lines: 97 } })with a comment that the console host (
NCode.Identity.ManagementConsole.Host) serves the bundle at the root of itsorigin under each response's CSP nonce.
main.tsx:const nonce = readCspNonce(document);const styleCache = createCache({ key: 'mgmt', container: document.head, ...(nonce ? { nonce } : {}) });const loadRuntimeConfig = createBffRuntimeConfigLoader();and renderStrictMode > CacheProvider(styleCache) > Provider(store) > BootstrapGate(load) > SessionGate > App. Then runnpx -y pnpm@10.34.7 install --prefer-offlineinsrc/frontendto update the lockfile.FE/apps/auth/src/mount.tsx(emotion cache with the nonce).Push-Location src/frontend; npx -y pnpm@10.34.7 install --frozen-lockfile; $a = $LASTEXITCODE; Pop-Location; $b = [int](-not (Select-String -Path src/frontend/apps/mgmt/vite.config.ts -Pattern 'defineBffServedSurfaceConfig' -Quiet)); $c = [int](Select-String -Path src/frontend/apps/mgmt/package.json -Pattern '@ncode/auth' -Quiet); if ($a -or $b -or $c) { exit 1 } else { exit 0 }[ ] 32. Show the BFF session in the console shell
MG/src/App.tsx,MG/src/App.test.tsxAppreadsconst user = useAppSelector((state) => state.session.user)andconst isSignedIn = useAppSelector((state) => state.session.status === 'signed-in'); the subject label isuser.name, else thepreferred_usernameclaim when it is a string, elseuser.sub;handleSignOutcallsannounceSessionEnded()thensubmitLogout(user)when a user is present. Delete the runtime/ID-token reads, thesignedOutdispatch andendSessionUrlFromRuntime, and rewrite the sign-out comment (the form post ends the BFFsession, revokes its refresh token and runs RP-initiated logout at the OP).
apiBaseUrl: 'https://api.test'; stores preloadsession: { status: 'signed-in', user }with aBffUserfixture (logoutUrl: '/bff/logout',logoutSid: 'guard')in place of
auth. Replace the session tests: the label shows the name, thepreferred_usernameclaim, or thesubject; sign-out submits a form to
/bff/logoutcarryingsid=guardandreturnUrl=/(spy onHTMLFormElement.prototype.submit); no session controls when signed out.Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/App.test.tsx; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 33. Rebase the console's tenant-surface tests on the BFF runtime (1/3)
MG/src/containers/ClientDetailPage.test.tsx,MG/src/containers/ClientsPage.test.tsx,MG/src/containers/GrantsPage.test.tsx,MG/src/containers/ResourceServerDetailPage.test.tsx,MG/src/containers/ResourceServerScopesPage.test.tsx,MG/src/containers/ResourceServersPage.test.tsxRuntimeConfigliteral (apiBaseUrl,issuer,authorizationEndpoint,tokenEndpoint,scopes,features) withconst runtime: BffRuntimeConfig = { kind: 'bff', apiBaseUrl: 'https://api.test', bff: { basePath: '/bff', antiforgeryHeader: { name: 'X-CSRF', value: '1' } }, features: {} };keeping the file's own
apiBaseUrlvalue; importBffRuntimeConfigin place ofRuntimeConfig; delete theauth: { … }member of every
createAppStore({ … })call. Change nothing else.Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/containers/ClientDetailPage.test.tsx src/containers/ClientsPage.test.tsx src/containers/GrantsPage.test.tsx src/containers/ResourceServerDetailPage.test.tsx src/containers/ResourceServerScopesPage.test.tsx src/containers/ResourceServersPage.test.tsx; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 34. Rebase the console's owner-surface tests on the BFF runtime (2/3)
MG/src/containers/OwnerEncryptionKeysTab.test.tsx,MG/src/containers/OwnerSecretsTab.test.tsx,MG/src/containers/SecretsManager.test.tsx,MG/src/containers/PlatformEncryptionKeysPage.test.tsx,MG/src/containers/PlatformSettingsPage.test.tsx,MG/src/containers/TenantDetailPage.test.tsxPush-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/containers/OwnerEncryptionKeysTab.test.tsx src/containers/OwnerSecretsTab.test.tsx src/containers/SecretsManager.test.tsx src/containers/PlatformEncryptionKeysPage.test.tsx src/containers/PlatformSettingsPage.test.tsx src/containers/TenantDetailPage.test.tsx; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 35. Rebase the console's team and home tests on the BFF runtime (3/3)
MG/src/containers/HomePage.test.tsx,MG/src/containers/TeamDetailPage.test.tsx,MG/src/containers/TeamsPage.test.tsx,MG/src/containers/TenantsPage.test.tsx,MG/src/useCallerAccess.test.tsxuseCallerAccess.test.tsxthe store preloads onlyconfig(the hook takesisSignedInas its argument).Push-Location src/frontend/apps/mgmt; npx -y pnpm@10.34.7 exec vitest run src/containers/HomePage.test.tsx src/containers/TeamDetailPage.test.tsx src/containers/TeamsPage.test.tsx src/containers/TenantsPage.test.tsx src/useCallerAccess.test.tsx; $c = $LASTEXITCODE; Pop-Location; exit $c[ ] 36. Pass the whole frontend gate
plan's design; escalate when a fix needs a decision)
src/frontendrunnpx -y pnpm@10.34.7 run format(Prettier writes), make sure no file undersrc/frontend/apps/mgmt/srcmentionsaccessToken,Bearer,@ncode/authorAuthGate, then run the gatebelow; the
@ncode/app-mgmtcoverage floors hold (97/95/100/97).Push-Location src/frontend; npx -y pnpm@10.34.7 run format:check; $a = $LASTEXITCODE; npx -y pnpm@10.34.7 run lint; $b = $LASTEXITCODE; npx -y pnpm@10.34.7 run typecheck; $c = $LASTEXITCODE; npx -y pnpm@10.34.7 run test:report; $d = $LASTEXITCODE; npx -y pnpm@10.34.7 run build; $e = $LASTEXITCODE; Pop-Location; $f = [int](Select-String -Path src/frontend/apps/mgmt/src/*.ts*, src/frontend/apps/mgmt/src/*/*.ts* -Pattern 'accessToken|Bearer|@ncode/auth|AuthGate' -Quiet); if ($a -or $b -or $c -or $d -or $e -or $f) { exit 1 } else { exit 0 }[ ] 37. Test the console host against the built console bundle in CI
MCT/BuiltConsoleBundleFactAttribute.cs(new),MCT/ManagementConsoleBundleReaderTests.cs,.github/workflows/frontend.ymlBuiltConsoleBundleFactAttributeliketests/NCode.Identity.OpenId.Authentication.Interactive.Tests/BuiltAuthBundleFactAttribute.cswith variable
NCODE_MGMT_BUNDLE_INDEXand theapps/mgmtwording. Add[BuiltConsoleBundleFact] public async Task RenderIndex_WhenGivenTheBuiltBundlesIndex_LeavesNoPlaceholderAndNoncesEveryScript():read the file, render with nonce
n0nce, assert no__NCODE_CSP_NONCE__remains, thecsp-noncemeta carriesnonce="n0nce", and every<scripttag carriesnonce="n0nce". Infrontend.yml, after "Test the host againstthe built login bundle", add the step "Test the console host against the built console bundle" with
NCODE_MGMT_BUNDLE_INDEX: ${{ github.workspace }}/src/frontend/apps/mgmt/dist/index.htmlrunningdotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj --nologo --filter "FullyQualifiedName~ManagementConsoleBundleReaderTests.RenderIndex_WhenGivenTheBuiltBundlesIndex" -- RunConfiguration.TreatNoTestsAsError=true(same
if, shell and working directory as the login-bundle step).$env:MSBUILDDISABLENODEREUSE = '1'; $env:NCODE_MGMT_BUNDLE_INDEX = (Resolve-Path src/frontend/apps/mgmt/dist/index.html).Path; dotnet test tests/NCode.Identity.ManagementConsole.Tests/NCode.Identity.ManagementConsole.Tests.csproj -m:2 --filter "FullyQualifiedName~ManagementConsoleBundleReaderTests.RenderIndex_WhenGivenTheBuiltBundlesIndex" -- RunConfiguration.TreatNoTestsAsError=true[ ] 38. Format and build the whole solution
.csfile the formatter rewrites; any file a build error names (fix within the plan's design)dotnet csharpier format ., then build the solution and fix every warning-as-error.$env:MSBUILDDISABLENODEREUSE = '1'; dotnet csharpier check .; $a = $LASTEXITCODE; dotnet build NCode.Identity.slnx -m:2; $b = $LASTEXITCODE; if ($a -or $b) { exit 1 } else { exit 0 }[ ] 39. Record the hosting decision (ADR-1437)
docs/decisions/1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md(new)docs/decisions/0000-template.md. Title# 1437. The management console is a confidential client served by a BFF host of its own. StatusAccepted (amends [ADR-1064](…), [ADR-1071](…), [ADR-1131](…), [ADR-1146](…), [ADR-1294](…)),Date 2026-11-05, Deciders NCode Group. Context (present tense): the console is the family's most privileged surface;
a browser public client holds its tokens where injected script reads them (RFC 10017 §5); the BFF library serves
several frontends (ADR-1432); the design's §11 question 6 asks whether the console's BFF runs in the OP host or a
separate host. Decision: D1 (with its reasons as a list), D2, D3 (including the
manage.<apex>routing:/apito theOP for machine callers, every other path to the console host), D4 and D6. Options considered: (a) the BFF in the OP
host with the console under
/mgmt/— rejected: the console's script would share an origin with the OP's login,consent and account pages, and the OP would carry a second composition and a client's keys; (b) the BFF in the OP
process on a host name of its own — rejected: origin isolation without process isolation, and
AddIdentityServerandAddIdentityBffin one service collection; (c) a separate host (chosen). Consequences: one more deployable (its imageand chart are follow-up work), the console reaches the OP over HTTP like any client, local development runs three
processes for the console (
api,console,mgmtbuild-watcher), back-channel logout is not delivered on adeveloper machine. References:
docs/backend-for-frontend.md§5.14 and §11, RFC 10017, the package and host paths,issue #158.
$f = 'docs/decisions/1437-the-management-console-is-a-confidential-client-served-by-a-bff-host-of-its-own.md'; if ((Test-Path $f) -and (Select-String -Path $f -Pattern '^# 1437\. ' -Quiet) -and (Select-String -Path $f -Pattern '^## Options considered' -Quiet) -and (Select-String -Path $f -Pattern 'manage\.<apex>' -Quiet)) { exit 0 } else { exit 1 }[ ] 40. Record the console's browser contract (ADR-1438)
docs/decisions/1438-the-management-console-holds-no-token-and-reads-its-session-from-its-bff.md(new)# 1438. The management console holds no token and reads its session from its BFF. StatusAccepted (amends [ADR-1312](…)). Context: what a BFF-served bundle needs at boot (its API base, its BFF's prefixand anti-forgery header) and does not (an issuer, a client id, scopes); ADR-1434's open-tabs pattern; a logout in one
tab racing a login in another. Decision: D7, D8 and D9 as numbered points (the bootstrap document shape, the
RuntimeConfigunion andloadBffRuntimeConfig, the session slice andfetchBffUser, the base query's header and401, the deleted@ncode/authmodules,SessionGate's probes and channel, the sign-in screen as the end state,the logout form, the page CSP and
defineBffServedSurfaceConfig); state that standalone dev servers proxy only thebootstrap document (the ADR-1312 amendment). Options considered: navigating to
/bff/loginon every end (rejected:the race, and a failing login would loop); keeping
@ncode/auth's custody with a token-mediating backend (rejected:RFC 10017 §6.2 leaves tokens in the browser); one
RuntimeConfigwith optional OpenID and BFF members (rejected:every consumer would test for members its surface always has). Consequences, References (ADR-1434, ADR-1437, RFC 10017).
$f = 'docs/decisions/1438-the-management-console-holds-no-token-and-reads-its-session-from-its-bff.md'; if ((Test-Path $f) -and (Select-String -Path $f -Pattern '^# 1438\. ' -Quiet) -and (Select-String -Path $f -Pattern 'ncode\.mgmt\.session' -Quiet)) { exit 0 } else { exit 1 }[ ] 41. Record the BFF's key rings from PEM files (ADR-1439)
docs/decisions/1439-a-bff-loads-the-key-rings-its-configuration-names-from-pem-files.md(new)# 1439. A BFF loads the key rings its configuration names from PEM files. StatusAccepted.Context: a frontend names its client and DPoP key rings (
ClientAuthentication:KeyRing,DPoP:KeyRing), and a BFFhost had to register each keyed
ISecretKeyCollectionProviderin code; a stateless BFF replica takes its keys from amounted secret. Decision: D5 in full (section, formats, EC-then-RSA reading,
kid= file stem, sensitive buffers,content-root resolution, startup validation messages, code registration as the seam for other sources). Options:
keys in the configuration's text (rejected: a private key in configuration values ends up in logs and dumps of the
configuration); a JWK set file (rejected: PEM is what tooling and secret stores emit); a
Sourcevocabulary for keyrings (rejected for now as unneeded: keyed DI already is the open seam). Consequences, References (ADR-1290, ADR-1382,
src/NCode.Identity.Bff/Secrets/BffPemKeyRing.cs).$f = 'docs/decisions/1439-a-bff-loads-the-key-rings-its-configuration-names-from-pem-files.md'; if ((Test-Path $f) -and (Select-String -Path $f -Pattern '^# 1439\. ' -Quiet) -and (Select-String -Path $f -Pattern 'Bff:KeyRings' -Quiet)) { exit 0 } else { exit 1 }[ ] 42. Index the records and mark the ones they amend
docs/decisions/README.md,docs/decisions/1064-frontend-presentation-platform.md,docs/decisions/1071-aspire-local-dev-orchestrator.md,docs/decisions/1131-hosted-platform-environment-model-and-hostname-scheme.md,docs/decisions/1146-a-development-seeder-registers-only-the-clients-its-configuration-declares.md,docs/decisions/1294-a-bff-client-is-registered-with-explicit-metadata-not-a-bff-security-profile.md,docs/decisions/1312-the-frontend-bootstrap-api-base-is-root-relative-unless-configured.mdAccepted (amends [1064](…), [1071](…), [1131](…), [1146](…), [1294](…))), 1438(
Accepted (amends [1312](…))) and 1439 (Accepted) after 1434, in the existing row format. Extend each amendedrecord's Status line (and its index row) with one clause, and change nothing else in the record: 1064 — "and by
ADR-1437: the management console is served by a BFF host of its own"; 1071 — "amended by ADR-1437: the AppHost runs
the management console's BFF host on
https://localhost:5005and build-watchesapps/mgmt" (read its Status linefirst and join the clause in its existing style); 1131 — "Accepted (amended by ADR-1437:
manage.<apex>serves themanagement console's BFF host, and routes
/apito the management API)"; 1146 — "and by ADR-1437: the seeder alsoregisters the confidential BFF clients its configuration declares"; 1294 — "and by ADR-1437: a client whose provider
cannot reach its
jwks_uriregisters its keys by value"; 1312 — "Accepted (amended by ADR-1438: the standalone devservers proxy only the bootstrap document)".
pwsh -NoProfile -File ./.template/Test-AdrIndex.ps1; if ($LASTEXITCODE) { exit 1 }; pwsh -NoProfile -File ./build/adr-index.ps1[ ] 43. Describe the console in the BFF design (status, §1.2, §4.1, §5.12)
docs/backend-for-frontend.mdNCode.Identity.Bffcore, remote APIs,browser hardening, multiple frontends and the management console are implemented (§9).
The library is tracked as issue [Backend for Frontend (BFF) library: cookie sessions with server-side token custody #123]…; each implemented area's decisions are ADRs." (keep the Backend for Frontend (BFF) library: cookie sessions with server-side token custody #123 link; delete the
sentence that calls the console pending and its Serve the management console through the BFF instead of as a browser public client #158 link). §1.2's last paragraph → present tense: the family's relying party is
NCode.Identity.Bff,and its own management console is a BFF frontend served by
NCode.Identity.ManagementConsole.Host, so the mostprivileged surface holds no token in the browser (§5.14). §4.1 table: add rows
NCode.Identity.ManagementConsole(depends onNCode.Identity.Bff.Server,NCode.AspNetCore; holdsAddManagementConsole,MapManagementConsole, the console's bootstrap document, page and assets — ADR-1437) andNCode.Identity.ManagementConsole.Host(the console's reference host). §5.12: after the composition sketch, aparagraph and a
jsoncsnippet forBff:KeyRings:{name}:PemFiles(D5, ADR-1439), and change the sketch'sAddKeyedSingleton<ISecretKeyCollectionProvider>(…)comment to say it is the seam for a ring held elsewhere, such asa KMS; add the key-ring failures to the startup-validation list.
$f = 'docs/backend-for-frontend.md'; if ((Select-String -Path $f -Pattern 'Bff:KeyRings' -Quiet) -and (Select-String -Path $f -Pattern 'NCode\.Identity\.ManagementConsole\.Host' -Quiet) -and -not (Select-String -Path $f -Pattern 'is pending' -Quiet)) { exit 0 } else { exit 1 }[ ] 44. Describe the console in the BFF design (§5.14, §9, §11)
docs/backend-for-frontend.md### 5.14 The management consoleafter §5.13: the topology (D1, D3) as a short list, a table of theconsole host's paths (
/and every non-file path → the bundle page under a nonce CSP;/assets/*→ immutablefiles;
/config/bootstrap.json→ the console's bootstrap document;/bff/*→ themgmtfrontend's endpoints;/management/api/*→ the OP's management API with the user's DPoP-boundurn:ncode:managementtoken), the clientregistration (D4), local development (D6), and the browser side (D7, D8), citing ADR-1437, ADR-1438, ADR-1439. §9:
replace the "Pending" row with a "Management console" row ("
NCode.Identity.ManagementConsole.Hostservesapps/mgmtas the confidential client
mgmt-consoleof the control-plane tenant (§5.14,[ADR-1437]…, [ADR-1438]…). The Playground stays a public client on purpose.") and add a "Key rings" row (ADR-1439).
§11 question 6: append "Decided: a separate host,
NCode.Identity.ManagementConsole.Host, on an origin of its own([ADR-1437]…)." and drop "(simplest)" / "(cleaner separation …)" from the question.
$f = 'docs/backend-for-frontend.md'; if ((Select-String -Path $f -Pattern '^### 5\.14 The management console' -Quiet) -and (Select-String -Path $f -Pattern '1437-the-management-console' -Quiet) -and -not (Select-String -Path $f -Pattern '^\| Pending' -Quiet)) { exit 0 } else { exit 1 }[ ] 45. Describe the console in the frontend README
src/frontend/README.mdmgmt/→ "Management console (served by its BFF host,NCode.Identity.ManagementConsole.Host)";the comment of the
pnpm --filter @ncode/app-mgmt devline becomes "build-watch the Management console's dist (itsBFF host serves it)". Serving models: add a BFF-served SPAs bullet (
apps/mgmt,vite build --watch,defineBffServedSurfaceConfig({ coverage }), base/, nonce placeholder, served by the console host) and make thestandalone bullet's example
apps/playground. Runtime configuration: show the BFF document beside the OpenID one,createBffRuntimeConfigLoader(), thekindunion; the standalone dev servers proxy only/config/bootstrap.json;the default document (no
?surface=) is the OP's default, not the console's. Replace "Authentication(authorization-code + PKCE)" with "Sessions": the console's
SessionGate,/bff/user, the anti-forgery header, the401→sessionEnded(), the logout form, the polling andBroadcastChannel(ADR-1434, ADR-1438), and one paragraphthat the Playground keeps
@ncode/authas a public client on purpose. Full stack: theconsoleresource onhttps://localhost:5005and themgmtbuild-watcher; drop 5173 and the CORS sentence about it. Gotchas: the run-onceref example becomes the Playground's
FlowExplorercallback completion (apps/playground/src/containers/FlowExplorer.tsx)and StrictMode's example
apps/playground/src/main.tsx.$f = 'src/frontend/README.md'; if ((Select-String -Path $f -Pattern 'defineBffServedSurfaceConfig' -Quiet) -and -not (Select-String -Path $f -Pattern '5173|AuthGate' -Quiet)) { exit 0 } else { exit 1 }[ ] 46. Describe the console host in the repository's guides
README.md,src/NCode.Identity.OpenId.Host/README.md,AGENTS.mdREADME.mdlocal-run paragraph: the AppHost brings up the provider athttps://localhost:5001, themanagement console at
https://localhost:5005, the flow explorer and the conformance dashboard. Host README'sdevelopment-credentials paragraph: the AppHost also writes
mgmt-client.pemandmgmt-dpop.pem(the console'sclient and DPoP keys) and hands the client key's public key set to the OP's developer seed; sign in at
https://localhost:5005asadmin.AGENTS.mdLayout: under Backend for frontend add the console:"
NCode.Identity.ManagementConsolecomposes the management console's BFF host, andNCode.Identity.ManagementConsole.Hostis its reference host (ADR-1437)"; and mentionBff:KeyRings([ADR-1439]…) besideAddIdentityBff(configuration).if ((Select-String -Path AGENTS.md -Pattern 'NCode\.Identity\.ManagementConsole\.Host' -Quiet) -and (Select-String -Path README.md -Pattern 'localhost:5005' -Quiet) -and (Select-String -Path src/NCode.Identity.OpenId.Host/README.md -Pattern 'mgmt-client\.pem' -Quiet)) { exit 0 } else { exit 1 }[ ] 47. Rewrite the changelog entries the console's hosting reshapes
CHANGELOG.md## [Unreleased]entries in place (find each by its quoted text):deterministic administrator user" → "provisions public (PKCE-only) single-page-application clients, confidential
BFF clients and a deterministic administrator user"; add after the
DeveloperSpaClientSeedparenthesis: "andexactly the BFF clients under
DeveloperInteractiveSeed:BffClients(DeveloperBffClientSeed:ClientId,Jwksregistered by value,
RedirectUris,PostLogoutRedirectUris,BackchannelLogoutUri; private_key_jwt, PAR andDPoP-bound tokens)"; "The reference host declares
mgmt-spaandplayground-spa(which requires consent)" → "Thereference host declares
playground-spa(which requires consent) and the management console's BFF clientmgmt-console, whose key set the AppHost supplies".IIdentityServerBuilder.AddFrontendBootstrap(configuration)": "publishesmgmt-spawith thedcrfeature flagand developer branding,
playground-spa" → "publishes developer branding,playground-spa".mgmt,playgroundandconformanceVite dev servers" → "the management console host(
console) and its build-watcher (mgmt), and theplaygroundandconformanceVite dev servers"; "andmgmt,playgroundandconformanceonhttps://localhost:5173,5174and5175, un-proxied" → "consoleonhttps://localhost:5005, andplaygroundandconformanceonhttps://localhost:5174and5175, un-proxied".mgmt,playgroundorconformance" →"
playgroundorconformance"; "ports 5173–5175" → "ports 5174–5175".@ncode/authholds the Playground'spublic-client protocol helpers." and keep only the package sentence (PKCE
S256verifier and challenge, theauthorize URL,
statevalidation, code exchange, refresh, the end-session URL and JWT claim decoding, injectable andunit-tested, including the RFC 7636 test vector). Delete the entry "The management console renews its access token
silently and signs out through RP-Initiated Logout." in full.
one shared gate."; "through
createRuntimeConfigLoader({ surface })" → "throughcreateRuntimeConfigLoader({ surface }), or the console throughcreateBffRuntimeConfigLoader()".models (standalone, backend-served and BFF-served)"; "and the Management console's dev server pins port 5173,
which its redirect URI and CORS origin assume" → "and each standalone dev server pins the port its redirect URI and
CORS origin assume".
standalone Vite dev servers proxy
/api/to the OP host".$c = Get-Content -Raw CHANGELOG.md; if ($c -match 'mgmt-spa|5173|renews its access token silently|signs in with authorization code \+ PKCE') { exit 1 } else { exit 0 }[ ] 48. Catalog the console's BFF host in the changelog
CHANGELOG.md## [Unreleased]→### Added, directly after the entry "One BFF serves several browser frontends.",add three entries, each ending with its links:
NCode.Identity.ManagementConsole(
AddManagementConsole(configuration),MapManagementConsole()) andNCode.Identity.ManagementConsole.Host: thebuilt
apps/mgmtbundle under a per-response CSP nonce,/config/bootstrap.json, themgmtfrontend's/bffendpoints, and
/management/api/*forwarded toManagementConsole:ManagementApiUrlwith the user's DPoP-boundurn:ncode:managementtoken; the confidential clientmgmt-consoleof the control-plane tenant (private_key_jwt,PAR, DPoP, back-channel logout);
https://localhost:5005under the AppHost, which generates the console's keys permachine (issue [Serve the management console through the BFF instead of as a browser public client #158]…, [ADR-1437]…).
Bff:KeyRings:{name}:PemFiles, EC or RSA,kid= file name,validated at startup ([ADR-1439]…).
RuntimeConfigunion andloadBffRuntimeConfig,the
sessionslice andfetchBffUser,SessionGate(poll, expiry and visibility probes,BroadcastChannel),sign-out by the logout form,
defineBffServedSurfaceConfig,readCspNoncein@ncode/ui([ADR-1438]…).Under
### Securityadd "The management console holds no token in the browser." — no access, refresh or IDtoken reaches the console's script; its calls carry the
__Host-Http-bff.mgmtcookie and the anti-forgery header,and the console's origin is separate from the OP's login, consent and account pages (RFC 10017, [ADR-1437]…,
[ADR-1438]…). Every link uses the full
https://github.com/NCodeGroup/Identity/issues/158form anddocs/decisions/<file>.mdpaths, as neighboring entries do.if ((Select-String -Path CHANGELOG.md -Pattern 'served by a BFF host of its own' -Quiet) -and (Select-String -Path CHANGELOG.md -Pattern 'holds no token in the browser' -Quiet) -and (Select-String -Path CHANGELOG.md -Pattern 'Bff:KeyRings' -Quiet)) { exit 0 } else { exit 1 }Review checklist
src/frontend/apps/mgmt,packages/storeorpackages/authholds or sends a token;@ncode/authexports only what
apps/playgroundimports.Program.csis composition-only; every behavior lives inNCode.Identity.ManagementConsoleandis tested (tested-or-excluded).
form-actionnames exactly'self'and the authority's origin; the bootstrap document and the page are
no-store; assets are immutable./management/api/*is limited to themgmtfrontend, needs the session and the anti-forgery header, and forwardsonly to
ManagementConsole:ManagementApiUrl;/apiis not mapped on the console host.mgmt-consolewith PAR, DPoP-bound tokens, private_key_jwt, back-channel logout and thekey set by value, and the OP's Development overlay has no
mgmt-spa, no 5173 origin and no default client id.Bff:KeyRingsreads key material through sensitive buffers, never logs a path's content, and fails startup for amissing file.
SessionGatenever navigates by itself; an end of session shows the sign-in screen in every tab.referenced.
https://localhost:5005asadmin, open a tenant's clients,sign out, and confirm the browser's storage and the network log carry no token.
Follow-ups
src/NCode.Identity.ManagementConsole.Hostthat buildsapps/mgmtinto the image (as the OP's Dockerfile buildsapps/auth), a CI image workflow, and a Helm chart component that mounts themgmt-client/mgmt-dpopPEM keys from secrets and routesmanage.<apex>(/apito the OP, the rest to the console), so the console has a production deployment.AddReverseProxy/UseReverseProxyandAddStrictTransportSecuritylive inNCode.Identity.Server, so the console host behind an ingress has no trusted forwarded-headers list or HSTS; move them to a package a BFF host can reference and compose them inNCode.Identity.ManagementConsole.Host.InteractiveBundleReaderandInteractiveBundleAssetEndpointHandler(NCode.Identity.OpenId.Authentication.Interactive) andManagementConsoleBundleReader/ManagementConsoleAssetHandler(NCode.Identity.ManagementConsole) duplicate the cached index read and the immutable asset serving; one general helper inNCode.AspNetCoreremoves the duplicate.polewskm commentedon Nov 6, 2026
Plan update (step 15, D4): the console logs in without
resource, so its login access token carries the OpenID audience that UserInfo requires (ADR-1027). The/management/apiremote API declaresResource = urn:ncode:management, so the BFF obtains a separate, audience-restricted, DPoP-bound management token through the refresh-token grant with RFC 8707resource, and no token is usable at both UserInfo and the management API. Step 15 dropsResourcesfrom the console host's configuration and proves a management call end to end.polewskm commentedon Nov 6, 2026
Landed in #606 (900071a): the management console is served by its own BFF host as the confidential client
mgmt-consoleand holds no token in the browser (ADR-1437, ADR-1438, ADR-1439). Follow-ups: #609, #610, #611, #612.