Start here

Getting started

Wire Sentinel into an ASP.NET Core app — EF Core stores, the engine, the authentication handler, the endpoint groups — and make your first login, refresh and authenticated call.

This walkthrough takes an empty ASP.NET Core app to a working identity provider: password login, token refresh with rotation, and an authenticated profile call. It mirrors the wiring Sentinel’s own HTTP test host uses, so every line here is exercised by the test suite.

Install

dotnet add package Nuvora.Nexus.Sentinel
dotnet add package Nuvora.Nexus.Sentinel.AspNetCore
dotnet add package Nuvora.Nexus.Sentinel.Stores.EfCore

The meta package brings Sentinel.Core (the framework-free domain engine) with it.

Register the services

Ordering matters once, and only once: stores before the engine. Every registration is TryAdd-idempotent, so the first one wins — registering the EF stores first lets them claim the ports (ISigningKeyStore, IAuditStore, IDefinitionCatalogStore, …) that AddSentinel() would otherwise default to in-memory implementations.

var builder = WebApplication.CreateBuilder(args);

// 1. Persistence: EF Core stores (PostgreSQL, SQL Server or SQLite).
builder.Services.AddSentinelEfCoreStores(o => o.UseNpgsql(connectionString));

// 2. The engine: login, tokens, snapshots, risk, abuse, audit.
builder.Services.AddSentinel();

// 3. HTTP: the authentication handler and endpoint plumbing.
builder.Services.AddSentinelAuthentication(o =>
{
    o.Issuer        = "https://id.example.com";
    o.Audience      = "example-api";
    o.DefaultRealmId = realmId;
    o.Transport     = SentinelTokenTransport.BearerAndCookie;
});

var app = builder.Build();

SentinelAspNetOptions refuses an empty Audience — audience enforcement is not optional in Sentinel; token type (typ) and audience are always validated.

What AddSentinel() deliberately does not default

Five ports have no default because a wrong guess would be a silent security decision: IUserStore, IMfaStore, ISessionStore, ISubjectDataSource and IMachineIdentityStore. AddSentinelEfCoreStores(...) provides all of them; if you skip it, you must register your own.

The database

The EF adapter exposes one model in two ways: use SentinelDbContext directly, or call modelBuilder.ApplySentinelModel() inside your own DbContext to host Sentinel’s tables next to yours. No migrations ship yet (the schema is still settling in Wave 1) — create the schema with Database.EnsureCreated() in dev/tests or host-managed tooling; EF migrations land before 1.0.

Mount the endpoints

app.UseAuthentication();

app.MapSentinelAuth();     // POST /auth/login, /auth/mfa/verify, /auth/refresh,
                           // /auth/logout, /auth/logout-all
app.MapSentinelProfile();  // GET /profile/me, /profile/sessions, /profile/permissions

Both accept a prefix argument (MapSentinelAuth("/identity")) and respect the host’s PathBase. The rest of the surface mounts the same way when you need it: MapSentinelPasskeys(), MapSentinelFederation(), MapSentinelOidc(), MapSentinelSaml(), MapSentinelScim(), MapSentinelAdmin().

Keys: fail fast, on purpose

Sentinel will not mint tokens with keys it invented silently. In production the signing key ring loads from the persisted store (the EF adapter stores PKCS#8 keys and handles rotation with a 7-day overlap window by default). For development you opt in to ephemeral keys explicitly:

// Development only — keys are regenerated on every start, loudly.
builder.Services.AddSingleton(new SentinelKeyOptions
{
    AllowEphemeralDevelopmentKeys = true,
});

Initialize the ring during startup with SentinelHost.InitializeAsync(app.Services) — it fails fast with an explanation rather than serving a broken JWKS.

First login

Seed a user through the EF stores (or the admin API), then:

POST /auth/login
Content-Type: application/json

{ "email": "ada@example.com", "password": "hunter2!" }

Three outcomes, all deliberate:

  • 200 with tokens{ "status": "ok", "accessToken": "…", "refreshToken": "srt_…" }. With the cookie transport, tokens travel as httpOnly cookies instead and the JSON body omits them; a non-httpOnly sentinel_csrf cookie pairs with the X-Sentinel-Csrf header for double-submit CSRF protection on unsafe methods.
  • 200 with "status": "mfa_required" — the body carries a single-purpose mfaPendingToken (a signed JWT with typ: "mfa+sentinel", five-minute lifetime) and the factor to satisfy ("totp" or "email_otp"). Complete with POST /auth/mfa/verify.
  • Problem+json with a stable error codeinvalid_credentials (401), blocked (429), or captcha_required (429, with the site key in the extensions). Messages are localized from Accept-Language; codes never change.

A failed lookup burns a dummy argon2id hash so that “no such user” and “wrong password” are indistinguishable by timing — see the user enumeration recipe.

Refresh, and what happens to thieves

POST /auth/refresh
Content-Type: application/json

{ "refreshToken": "srt_…" }

Every refresh rotates: the old token is atomically marked used, a new srt_ token is issued in the same family. Present a rotated token twice and Sentinel revokes the entire family and emits token.refresh_reuse_detected — the thief and the victim both lose the session, and the victim’s next login is the alarm. The token theft recipe walks through the mechanism and the tests that pin it.

The authenticated call

GET /profile/me
Authorization: Bearer eyJ…

The Sentinel authentication handler establishes a SentinelPrincipal from — in order — the Authorization: Bearer header, the access-token cookie, or an snt_ API key. Your handlers read it via HttpContext.GetSentinelPrincipal() or the injected ISentinelContextAccessor:

app.MapGet("/reports", (HttpContext http) =>
{
    var principal = http.GetSentinelPrincipal(); // SubjectId, RealmId, OrganizationId,
                                                 // SessionId, Kind, ImpersonatorId …
    var snapshot  = http.GetSentinelSnapshot();  // the permission snapshot
    // …
});

Where to go next

Learn by building

The tutorials for this area, in order — each with a runnable sample.