Skip to main content

Authentication and Identity

How the NimBus management WebApp decides who is signed in, what to enable where, and how operators sign in and out.

This is the operator-facing reference. For the SDK-level AddNimBusIdentity API surface, see sdk-api-reference.md § Identity Extension; for storage prerequisites and the local Aspire walk-through, see storage-providers.md.

The three auth providers​

src/NimBus.WebApp/Startup.cs picks exactly one of these per request, based on what's configured:

ProviderWhen it kicks inBest for
Local-dev bypassEnableLocalDevAuthentication=true AND the host is DevelopmentOne-keystroke local runs; never use in production
Username / password (NimBus.Extensions.Identity)NimBusIdentity:ConnectionString is setSQL-backed deployments where Entra registration isn't available, plus all local sign-in testing
Microsoft Entra IDAzureAd:ClientId is setOrganisations already on Entra; recommended default for production
Dual (Identity + Entra)Both Identity and Entra are configuredMigration windows where some users live in Identity and some in Entra

The selection ladder lives in Startup.cs:60–165. The fallback redirect (Startup.cs:400–412) sends unauthenticated requests to /account/login when Identity is present, otherwise /login for the Entra OIDC flow.

Security guard. BypassEndpointAuthorization and EnableLocalDevAuthentication throw InvalidOperationException at startup when set in a non-Development environment. There's no way to ship them to production accidentally.

Agent API keys​

Agents built on NimBus.Agents call the agent API (/api/agent/*) without a person signed in. A site Owner issues each agent its own key in Settings → Agent keys, or through the admin API below. The design is Spec 039.

RequestDoes
GET /api/admin/agent-keysLists the keys, without their secrets, and returns the csrfToken that the requests below must send as the X-NimBus-CSRF header
POST /api/admin/agent-keysIssues a key. Body: agentId (required), optional name and expiresAtUtc. The response's key is the full key
POST /api/admin/agent-keys/{keyId}/revokeRevokes a key
DELETE /api/admin/agent-keys/{keyId}Removes a revoked or expired key
  • Works in every sign-in mode, Identity-only included. The key travels in the X-NimBus-Agent-Key header and has a scheme of its own, AgentApiKey, which is never a default.
  • Admits only /api/agent/*. A key gets 401 on every other endpoint, and its principal carries no admin marker and no object id, so it cannot hold a site or endpoint role. Signed-in people keep using the agent API as before.
  • The key names the agent. Schema definitions, subscriptions and the audit row of a settled hand-off record the key's agent. A request whose X-Agent-Id names a different agent gets 403.
  • Shown once, stored hashed. The full key (nbak_<keyId>_<secret>) appears once, when it is issued. nimbus-ops stores only a SHA-256 of the secret. Issuing, revoking and removing are Owner-only and always audited, with the key id but never the key.
  • Revocation takes effect within 30 seconds on every instance, and at once on the instance that saved it. A key can also carry an expiry. "Last used" is updated at most every 5 minutes per instance.

Give the key to the agent through configuration, never source code:

builder.Services.AddNimBusAgent<MyHandler, MyEvent>(o =>
{
o.AgentId = "enrichment-agent"; // must match the key's agent
o.ApiKey = builder.Configuration["NimBus:Agent:ApiKey"];
o.Subscribe("MyEvent");
});

The local Aspire AppHost's development sign-in bypass admits agents without a key.

Dashboard tokens​

A wall display shows the Monitor without a person signed in. A site Owner issues each wall its own token in Settings → Dashboard tokens and opens the link it gives, /Monitor#token=nbdt_…, on the wall. See The Monitor wall.

RequestDoes
GET /api/admin/dashboard-tokensLists the tokens, without their secrets, and returns the csrfToken that the requests below must send as the X-NimBus-CSRF header
POST /api/admin/dashboard-tokensIssues a token. Body: name (required, up to 100 characters), optional expiresAtUtc. The response's token is the full token
POST /api/admin/dashboard-tokens/{tokenId}/revokeRevokes a token
DELETE /api/admin/dashboard-tokens/{tokenId}Removes a revoked or expired token
  • Works in every sign-in mode. The page sends the token in the X-NimBus-Dashboard-Token header, to a scheme of its own, DashboardToken, which is never a default.
  • Admits only the Monitor feed (/api/monitor/feed/*). A token gets 401 on every other endpoint, ACK included, and its principal carries no admin marker, email or object id, so it cannot hold a site or endpoint role.
  • The URL is the credential. The token travels in the URL fragment, after the #, which a browser never sends to a server: it stays out of request logs, Application Insights and Referer. It does land in the wall browser's history and bookmarks, so issue one token per wall and revoke a wall's token when the screen is retired.
  • Shown once, stored hashed. The full token (nbdt_<tokenId>_<secret>) appears once, as part of the link. nimbus-ops stores only a SHA-256 of the secret. Issuing, revoking and removing are Owner-only and always audited (DashboardTokenCreated, DashboardTokenRevoked, DashboardTokenRemoved), with the token id but never the token.
  • Revocation takes effect within 30 seconds on every instance, and at once on the instance that saved it. While the store is unreachable an instance keeps trusting its last read for at most 2 minutes, then refuses every token. A token can carry an expiry; Settings proposes one year.
  • Needs a storage provider that stores tokens. Cosmos DB, SQL Server and the in-memory store do. Without one the admin API answers 501.

The local Aspire AppHost's development sign-in bypass signs every request in, so a token-only wall can only be tried with NIMBUS_IDENTITY=true.

Enabling username/password​

The Identity provider needs three things: the package wired in (already shipped via NimBus.WebApp.csproj ProjectReference), a SQL connection string, and at least one user.

Locally with Aspire​

$env:NIMBUS_IDENTITY = "true"
dotnet run --project src/NimBus.AppHost

Docker Desktop must be running — Aspire pulls the mssql container, creates the nimbusdb database, applies the Identity schema, and seeds the bootstrap admin on first run. No user-secrets configuration is needed for the SQL side. The AppHost prints a banner with the default credentials. See storage-providers.md § Local sign-in for the full walk-through.

On Azure (via the nb CLI)​

$env:NIMBUS_SQL_ADMIN_PASSWORD = '<sql-pwd>'
$env:NIMBUS_IDENTITY_ADMIN_PASSWORD = '<pwd>'
nb setup `
--solution-id nimbus --environment dev `
--resource-group rg-nimbus-dev --location northeurope `
--storage-provider sqlserver --sql-mode provision `
--sql-admin-login <sql-admin> `
--identity-admin-email <admin-email>
Remove-Item Env:NIMBUS_SQL_ADMIN_PASSWORD
Remove-Item Env:NIMBUS_IDENTITY_ADMIN_PASSWORD

The bicep wires the WebApp app settings (deploy/bicep/deploy.webapp.bicep), seeding NimBusIdentity__* from the CLI input. RequireEmailConfirmation is forced to false so the bootstrap admin can sign in without SMTP setup.

Post-deploy hygiene. Remove NimBusIdentity__Bootstrap__Password from the WebApp app settings after the first sign-in. The hosted service is idempotent — it no-ops once any user exists — so leaving the settings in is functionally harmless, but the password sitting in plain Azure config is a leak waiting to happen.

Bootstrap admin​

When NimBusIdentity:Bootstrap:Email and Bootstrap:Password are both set AND the user store is empty, IdentityInitializerHostedService (in NimBus.Extensions.Identity) creates one NimBusUser with EmailConfirmed=true during host start-up. The service also creates the Identity schema (default nimbus) and the eight AspNet* tables if they're not there yet.

Idempotency means the bootstrap is fire-and-forget:

  • Drop the nimbus.AspNet* tables and the next boot re-seeds.
  • Change the password from the management UI after first sign-in; later boots no-op even with the original bootstrap env vars still set.

Signing in and out​

Endpoints​

MethodRoutePurpose
GET/account/loginLogin form (Razor view from Identity package)
POST/account/loginSubmit credentials
GET/account/registerSelf-registration form
POST/account/registerCreate a user
GET/account/forgot-passwordPassword-reset request (sends mail when SMTP configured)
POST/account/reset-passwordApply a reset token
GET/account/confirm-emailEmail verification callback
POST/account/logoutRazor form-post logout
GET/api/auth/meSPA-facing current-user JSON (200 + isAuthenticated)
POST/api/auth/logoutSPA-facing logout (204)

The management WebApp's sidebar shows the signed-in user's email plus a sign-out icon button in its footer when /api/auth/me reports an authenticated session. The button POSTs to /api/auth/logout and hard-navigates to /account/login so cookie state on the next page is clean. On Entra-only slots the endpoint returns 404 and the footer is hidden — the sidebar looks the same as before.

PropertyValue
NameNimBus.Identity
HttpOnlytrue
Securealways
SameSiteLax (default)
Lifetime8 hours, sliding expiration
Login redirect/account/login
Logout redirect/account/login

Configured at src/NimBus.Extensions.Identity/ServiceCollectionExtensions.cs:54–64.

Password policy​

Enforced by AddIdentity at registration time (ServiceCollectionExtensions.cs:39–50):

  • 8 characters minimum
  • one lowercase, one uppercase, one digit
  • non-alphanumeric not required
  • unique email required
  • account locks after 5 failed attempts for 15 minutes

Password policies aren't configurable via NimBusIdentityOptions today. If you need to relax or tighten them, fork the registration call.

Cross-site request forgery​

A write from a browser session must carry an antiforgery token in the X-NimBus-CSRF header. A browser session here means a POST, PUT, PATCH or DELETE that carries a sign-in cookie: the Entra cookie or NimBus.Identity. Those are the only requests a forged cross-site request can use, because the browser attaches the cookie by itself. A missing or wrong token gets 400. This adds to the cookie's SameSite=Lax, which already keeps it off cross-site POSTs in current browsers.

  • The SPA does it for you. Every GET /api/… from a browser session returns the token in the readable NimBus.CSRF cookie (with the HttpOnly antiforgery cookie it pairs with), and the generated API client sends it on every write.
  • Header-authenticated callers need no token: Entra bearer tokens, agent API keys (X-NimBus-Agent-Key), dashboard tokens (X-NimBus-Dashboard-Token, read-only) and the MCP endpoint. A browser can't attach their headers to a cross-site request.
  • Agent keys and MCP access are stricter. Their writes need the token even without a cookie, and their GET returns it as csrfToken.
  • The local development bypass is not covered. EnableLocalDevAuthentication signs every request in without a cookie. It works only in the Development environment.

The rule is applied by CookieAntiforgeryConvention to every generated API controller except the anonymous storage hook (src/NimBus.WebApp/Csrf/). The design is docs/plan/2026-10-29-antiforgery-cookie-api-writes.md.

Configuration reference​

All NimBusIdentity:* keys (read from any standard ASP.NET Core configuration provider — appsettings.json, env vars with __ separator, user-secrets, Azure App Service settings):

KeyDefaultNotes
NimBusIdentity:ConnectionString(unset)When set, opts the WebApp into the Identity-only auth branch. The same SQL DB can host both the message store and Identity (different schemas).
NimBusIdentity:SchemanimbusSchema for the eight AspNet* tables. Must be a SQL-safe identifier.
NimBusIdentity:RequireEmailConfirmationtruefalse lets the bootstrap admin sign in without SMTP.
NimBusIdentity:EnableEntraIdLoginfalseShows a Sign in with Microsoft button on /account/login.
NimBusIdentity:Bootstrap:Email(unset)First-admin email — seeded once when the user store is empty.
NimBusIdentity:Bootstrap:Password(unset)First-admin password.
NimBusIdentity:Bootstrap:DisplayName(unset)Optional display name for the seeded admin.
NimBusIdentity:Smtp:Host(unset)SMTP server for confirmation / reset mail. Warns if empty.
NimBusIdentity:Smtp:Port587
NimBusIdentity:Smtp:FromAddress(unset)
NimBusIdentity:Smtp:FromNameNimBus
NimBusIdentity:Smtp:Username(unset)Optional SMTP auth.
NimBusIdentity:Smtp:Password(unset)Optional SMTP auth.
NimBusIdentity:Smtp:UseSsltrue

Authorization: storage-backed roles (spec 026)​

Authorization (as opposed to authentication) uses storage-backed access-control lists: Reader < Contributor < Owner site-wide and per-endpoint, plus the orthogonal PII Reader capability (raw payload reveal — not implied by Owner). Site Owners manage the lists on the WebApp's Access Control page; entries are email addresses or Entra object IDs. Grants are stored in the message store (Cosmos container accesscontrol / SQL table AccessControl) and take effect immediately on the mutating instance (≤45s elsewhere).

Bootstrap / compat. The internal EIP_Management marker claim maps to site Owner, so the first Owner never locks themselves out of an empty store. The marker is materialized by any of:

SourceHow
Authorization:AdminGroupObjectIdsEntra group object IDs (array or comma-separated) whose members become site Owners — requires the app registration to emit group claims (groupMembershipClaims: SecurityGroup)
Authorization:AdminUserObjectIdsIndividual Entra user object IDs (oid) that become site Owners
LocalDevAuthHandlerLocal dev sign-ins are site Owners (but NOT PII Readers)
Identity role mappingAn Identity user in a role named EIP_Management

On App Service, set e.g. Authorization__AdminUserObjectIds = <user-object-id> (applied by EntraAdminClaimsTransformation on the Entra-only auth branch). The deploy bicep preserves Authorization__* app settings across infra re-applies, same as AzureAd__*. Code-defined endpoint.RoleAssignments object IDs map to endpoint Owner. Authorization:GrantPiiReaderInDevelopment grants PII Reader in Development only (startup fail-fast elsewhere).

Entra ID configuration (when AzureAd:ClientId is set, the WebApp takes the Entra branch — and the dual branch when Identity is also configured):

KeyRequiredNotes
AzureAd:InstanceyesUsually https://login.microsoftonline.com/
AzureAd:TenantIdyesTenant GUID
AzureAd:ClientIdyesApp-registration client ID — the trigger for the Entra branch
AzureAd:Domainyes<tenant>.onmicrosoft.com
AzureAd:CallbackPathyesTypically /signin-oidc
AzureAd:ClientSecretconditionalConfidential client; can be replaced by a federated credential

When no provider is configured (no local-dev bypass, no Identity and an empty AzureAd:ClientId), the WebApp refuses to start with No sign-in method is configured for the NimBus WebApp. Before this check it started and then failed every request with IDW10106: The 'ClientId' option must be provided. The fix is to configure one provider: Identity for SQL-backed deployments without an Entra registration, otherwise Entra. The Aspire AppHosts turn on the local-dev bypass themselves unless Identity is enabled.

Application Insights log queries​

The management API's event logs endpoint (GET /api/event/details/{endpointId}/{id}/logs) reads an event's traces through the Application Insights query API (https://api.applicationinsights.io/v1/apps/{AppInsights:ApplicationId}/query). Microsoft retired API keys for that API on 2026-03-31 (announcement), so the WebApp sends a Microsoft Entra bearer token instead. It gets the token from DefaultAzureCredential for the scope https://api.applicationinsights.io/.default.

Where the WebApp runsIdentityAccess it needs
App ServiceThe site's system-assigned managed identityReader on the Application Insights component. deploy.webapp.bicep grants it whenever appInsightsAppId is set, which nb infra apply always does
LocallyYour developer credential (Azure CLI, Visual Studio, …)Reader, or any role with Microsoft.Insights/components/query/read, on the component

Reader is the role Microsoft documents for Entra-authenticated queries. Monitoring Reader works too, but it adds permissions the WebApp doesn't need.

  • With AppInsights:ApplicationId empty (the local default), the WebApp requests no token and the endpoint returns no logs.
  • Locally, if the token request fails with ManagedIdentityCredential authentication failed, set AZURE_TOKEN_CREDENTIALS=dev so DefaultAzureCredential skips managed identity and uses your developer credential.
  • AppInsights:ApiKey is no longer read. nb infra apply no longer creates or deletes the management-app API key. deploy.webapp.bicep still accepts its apiKey parameter but ignores it; the parameter is deprecated and is removed in the next major version.
  • An existing management-app key can't query any more, and nb infra apply now leaves it in place. To remove it, run az monitor app-insights api-key delete --app <component> --resource-group <rg> --api-key management-app.
  • When a query fails, the WebApp logs a GetEventDetailsLogsIdAsync warning and the endpoint returns an empty list. A 403 usually means the role assignment is missing or hasn't propagated yet (allow a few minutes after the deployment). If the component is workspace-based and its Log Analytics workspace uses the Require workspace permissions access control mode, Reader on the component isn't enough: also grant the identity read access to the workspace (access control modes).

Where things live (source pointers)​

ConcernFile
Auth branch laddersrc/NimBus.WebApp/Startup.cs:60–165
Fallback redirect to loginsrc/NimBus.WebApp/Startup.cs:400–412
AddNimBusIdentity DI registrationsrc/NimBus.Extensions.Identity/ServiceCollectionExtensions.cs
Schema creation + bootstrap adminsrc/NimBus.Extensions.Identity/Services/IdentityInitializerHostedService.cs
Razor login / register / reset viewssrc/NimBus.Extensions.Identity/Controllers/AccountController.cs
SPA-facing /api/auth/*src/NimBus.Extensions.Identity/Controllers/AuthApiController.cs
Sidebar user footer + sign-outsrc/NimBus.WebApp/ClientApp/src/components/sidebar-user-footer.tsx
Aspire opt-in (NIMBUS_IDENTITY)src/NimBus.AppHost/Program.cs
Bicep app-setting wiringdeploy/bicep/deploy.webapp.bicep
Application Insights query tokensrc/NimBus.WebApp/Services/ApplicationInsights/ApplicationInsightsAuthenticationHandler.cs
WebApp role assignments (incl. Reader on Application Insights)deploy/bicep/templates/roleAssignments.bicep
CLI flags for the bootstrap adminsrc/NimBus.CommandLine/Program.cs (search --identity-admin-)

See also​