NE.Standard

Security

Authorization runs entirely in the framework, against a session it stores itself; the host's own authentication (cookies, OIDC, whatever ASP.NET Core is configured with) is a separate concern the framework only consumes when told to.

Sessions

Every request resolves to a UserSessionStateSessionId, Language, ThemeMode, IsAuthenticated, UserId, Roles, Permissions — held by IUserSessionStore (TryGetAsync/SaveAsync/RemoveAsync/ CleanupAsync). The shipped store keeps sessions in memory, which is wrong once there is more than one process. On the web the id travels in an HttpOnly cookie (SameSite=Lax, Secure when the request is HTTPS) named UISessionOptions.ClientKey (default "ne.ui.session"); only the shell render can write it, and the SignalR hub reads the same cookie off its negotiate request so both halves of a page load share one session. ConfigureSessions sets IdleTimeout (default 2 hours — how long a session survives unused), CleanupInterval (default 5 minutes), ClientKey, and ClientKeyLifetime:

application.ConfigureSessions(o =>
{
    o.IdleTimeout = TimeSpan.FromHours(8);
    o.ClientKeyLifetime = TimeSpan.FromDays(30);
});

ClientKeyLifetime is unset by default, and then the cookie lives as long as the browser window: closing it is a sign-out, whatever IdleTimeout says. Set, the cookie carries that lifetime and is written again on every page load, so it counts from the last visit — which only means something with a session store that outlives the host (the default store is in memory; the mini application keeps its sessions in SQLite).

Signing in

A controller changes the session through UIContext, never by writing to a UserSessionState directly (it is an immutable record):

[UICommand]
public async Task SignInAsync(string userId, string password, CancellationToken cancellationToken)
{
    if (!await _users.VerifyAsync(userId, password, cancellationToken))
        throw new UnauthorizedAccessException("Wrong username or password.");

    await Context.SignInAsync(userId, roles: new HashSet<string> { "User" }, cancellationToken: cancellationToken);

    // Sign-in must end in a navigation, or the old session id stays valid.
}
  • Context.SignInAsync(userId, roles, permissions, cancellationToken) marks the session authenticated and gives it its roles and permissions.
  • Context.SignOutAsync(cancellationToken) removes the session outright — every later command on the connection is then refused.
  • Context.UpdateSessionAsync(update, cancellationToken) applies any other change that must outlive the connection — language, theme, anything else — as session => session with { ... }; a no-op if the session is already gone.

Sign-in rotates the session id, as a defence against session fixation — but the rotation happens only at the next shell render, the one half of a page load that can write the cookie. SignInAsync only sets PendingIdRotation; a command that signs in without returning a NavigateEffect (or otherwise causing a full navigation) leaves the old, pre-rotation id valid.

[UIAuthorize] and [UIAllowAnonymous]

Both attach to a view, a controller class, or an individual command method:

[UIAuthorize(Roles = "Admin,Support", Permissions = "billing.view", RolesMode = UIAccessMode.Any)]
internal sealed partial class BillingController : UIControllerBase { ... }

UIAuthorizeAttribute takes a comma-separated Roles string (constructor) and Permissions (init-only), plus RolesMode/PermissionsMode (UIAccessMode.Any — the default — or .All). [UIAllowAnonymous] takes no arguments and lets the route or command resolve with no authenticated session. Both are collected into UIAccessRules (UIAccessRule.FromAttributes) and evaluated by IUIAuthorizationService.IsAuthorized(session, rules) — every rule must pass, and within a rule roles and permissions are checked independently under their own mode.

A route with neither attribute is open by default. That suits a public site and is a trap behind a login, where a forgotten attribute publishes a page — ConfigureSecurity(o => o.DefaultPolicy = UIAuthorizationDefault.Authenticated) inverts the default so the same omission closes a page instead. An explicit attribute on the view or controller always wins over the policy, and Require(rule) on UIRouteDefinitionBuilder adds further rules the same attribute would.

A command with no [UIAuthorize]/[UIAllowAnonymous] of its own follows its route: IUICommandMetadata.AllowAnonymous is bool?, and null reads UIContext.Route.AllowAnonymous — one DefaultPolicy therefore governs a page and every command on it together.

Where permissions are read

Route access is checked against the session resolved for that request, so it is always current. A command is checked against the session store (EnsureCommandAuthorizedAsync), not against UIHandle.Session — that one is a snapshot taken when the connection attached and refreshed only on the next attach, so checking it would let a revoked role go on granting access to an already-open tab.

UISecurityOptions

Set through application.ConfigureSecurity(configure):

  • DefaultPolicyUIAuthorizationDefault.Anonymous (default) or .Authenticated; see above.
  • SignInRoute/ForbiddenRoute — set by UIApplicationBuilder.SignInView<TView>(route)/ ForbiddenView<TView>(route) (each with a controller-backed overload), which also register the route anonymous, since a page that explains a refusal must not be able to refuse anyone itself.
  • IdentitySourceUIIdentitySource.Session (default: the application signs users in itself, and any ClaimsPrincipal the host supplies is ignored) or .Claims (the host's ClaimsPrincipal, via HttpContext.User on the web, is authoritative in both directions, mapped by IUserClaimsMapper.Map). The framework consumes a principal and never authenticates — the host owns AddAuthentication().
  • PermissionClaimType — the claim type permissions are read from under Claims (default "permission"); roles need no equivalent, since a ClaimsIdentity already declares its own RoleClaimType.
application.ConfigureSecurity(o =>
{
    o.DefaultPolicy = UIAuthorizationDefault.Authenticated;
});

application.SignInView<SignInView, SignInController>("/sign-in");
application.ForbiddenView<ForbiddenView>("/forbidden");

Refusals

Two exceptions mean two different things, and are routed differently by the built-in resolve-exception handler:

  • No identity yet — a plain UnauthorizedAccessException — goes to Security.SignInRoute with the refused route as the returnUrl navigation parameter.
  • An authenticated session that fails the rulesUIForbiddenAccessException — goes to Security.ForbiddenRoute with the refused route as deniedUrl, falling back to the sign-in route when no forbidden view is registered. EnsureCommandAuthorizedAsync throws the former when the store holds no live session, and the latter when the session fails an access rule.

With neither SignInView nor ForbiddenView configured, the exception propagates unchanged rather than being redirected.

A command's refusal is not a navigation. The redirects above belong to view resolution — opening a page. A command that fails its [UIAuthorize], or any command that throws, leaves the page where it is: the runtime turns the failure into a notification (CommandRefusedMessage for a refusal, CommandFailedMessage for the rest, see ConfigureErrorHandling), and the user sees a toast, not a sign-in page. So the attribute is the whole of a per-command role check; there is no need to re-test the role by hand inside the command to get a graceful answer.

IUIAuthorizationService and IUserClaimsMapper

IUIAuthorizationService.IsAuthorized(session, rules)/IsAuthorized(session, rule) is the seam evaluating access rules — the shipped StandardAuthorizationService checks roles and permissions independently, each under its own Any/All mode, and every rule in a list must pass. IUserClaimsMapper.Map(ClaimsPrincipal) turns the host's principal into a UserClaimsIdentity (IsAuthenticated, UserId, Roles, Permissions) under UIIdentitySource.Claims — replace the registered implementation to map an application's own claim shapes. Both are ordinary DI services, replaceable with services.AddSingleton<IUIAuthorizationService, MyAuthorizationService>().

The web host's own authorization

WebEndpointOptions.RequireAuthorization and AuthorizationPolicy gate the framework's two ASP.NET Core endpoints — the SignalR hub and the catch-all shell route — with ordinary ASP.NET Core authorization, independent of everything above:

services.Configure<WebEndpointOptions>(o =>
{
    o.RequireAuthorization = true;
    o.AuthorizationPolicy = "MyPolicy";
});

This is the host deciding who may reach the framework at all (useful behind a reverse proxy or a corporate SSO gate); the framework's own [UIAuthorize]/DefaultPolicy machinery above still runs independently on top of whatever gets through.