Nivora.Identity
0.1.12
dotnet add package Nivora.Identity --version 0.1.12
NuGet\Install-Package Nivora.Identity -Version 0.1.12
<PackageReference Include="Nivora.Identity" Version="0.1.12" />
<PackageVersion Include="Nivora.Identity" Version="0.1.12" />
<PackageReference Include="Nivora.Identity" />
paket add Nivora.Identity --version 0.1.12
#r "nuget: Nivora.Identity, 0.1.12"
#:package Nivora.Identity@0.1.12
#addin nuget:?package=Nivora.Identity&version=0.1.12
#tool nuget:?package=Nivora.Identity&version=0.1.12
Nivora.Identity
A plug-and-play JWT authentication library for ASP.NET Core Minimal API applications.
Provides registration, login, token refresh, logout, password reset, email confirmation, phone verification, change password, external login (host-verified), TOTP two-factor authentication, and a current-user endpoint out of the box.
Table of Contents
- Installation
- Quick Start
- Configuration
- Host App Integration
- Custom Table Names
- API Endpoints
- Password Reset Flow
- Email Confirmation Flow
- Phone Verification Flow
- TOTP Two-Factor Authentication
- Admin / Provisioning Service
- Roles
- External Login (Host-Verified)
- Claims Enrichment
- Keyless Projection Models
- Public Surface
- Database Schema
- Development & Testing
Installation
Add the NuGet package to your ASP.NET Core host project:
dotnet add package Nivora.Identity
The library targets net10.0 and depends on:
Microsoft.AspNetCore.Authentication.JwtBearerMicrosoft.EntityFrameworkCore.Relational
Quick Start
Three lines are all you need in a minimal API host:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// 1) Register your DbContext (any EF Core provider)
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// 2) Register Nivora Identity
builder.Services.AddNivoraIdentity<AppDbContext>(opts =>
{
opts.SigningKey = builder.Configuration["Jwt:Key"]!;
});
var app = builder.Build();
// 3) Map the auth endpoints
app.UseAuthentication();
app.UseAuthorization();
app.MapNivoraIdentityEndpoints();
app.Run();
And in your DbContext:
// AppDbContext.cs
using Nivora.Identity.Persistence;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddNivoraIdentitySchema();
}
}
That's it. Run dotnet ef migrations add AddIdentity and dotnet ef database update to create the tables.
Configuration
Option A — Lambda
builder.Services.AddNivoraIdentity<AppDbContext>(opts =>
{
opts.SigningKey = "your-secret-key-at-least-32-characters-long";
opts.Issuer = "MyApp"; // default: "Nivora"
opts.Audience = "MyApp"; // default: "Nivora"
opts.AccessTokenMinutes = 30; // default: 15
opts.RefreshTokenDays = 7; // default: 14
opts.MaxFailedAccessAttempts = 5; // default: 5
opts.LockoutMinutes = 15; // default: 15
opts.PasswordResetTokenMinutes = 15; // default: 15
opts.EmailConfirmationTokenMinutes = 60; // default: 60
opts.AllowExternalLoginAutoCreate = true; // default: true
opts.PhoneVerificationTokenMinutes = 10; // default: 10
opts.PhoneVerificationCodeLength = 6; // default: 6 (numeric)
opts.PhoneVerificationMaxAttemptsPerWindow = 5; // default: 5 (for throttle hook)
opts.TwoFactorChallengeMinutes = 5; // default: 5
});
Option B — Configuration section
// appsettings.json
{
"NivoraIdentity": {
"SigningKey": "your-secret-key-at-least-32-characters-long",
"Issuer": "MyApp",
"Audience": "MyApp",
"AccessTokenMinutes": 30,
"RefreshTokenDays": 7,
"MaxFailedAccessAttempts": 5,
"LockoutMinutes": 15,
"PasswordResetTokenMinutes": 15,
"EmailConfirmationTokenMinutes": 60,
"AllowExternalLoginAutoCreate": true,
"PhoneVerificationTokenMinutes": 10,
"PhoneVerificationCodeLength": 6,
"PhoneVerificationMaxAttemptsPerWindow": 5,
"TwoFactorChallengeMinutes": 5
}
}
builder.Services.AddNivoraIdentity<AppDbContext>(
builder.Configuration.GetSection("NivoraIdentity"));
Startup validation
SigningKey is required. If it is missing or empty, the application will throw a
meaningful error at startup via ValidateOnStart().
Host App Integration
What AddNivoraIdentity<TContext>() registers
| Registration | Lifetime | Description |
|---|---|---|
NivoraIdentityOptions |
Options | Bound & validated at startup |
AuthService |
Scoped | Internal — handles all auth operations (register, login, refresh, logout, password reset, email confirmation, change password, me) |
TokenService |
Scoped | Internal — JWT generation |
INivoraIdentityFacade |
Scoped | Public — user-facing auth operations (same-process, no HTTP required) |
IIdentityAdminService |
Scoped | Public — admin/seeding operations |
IIdentityThrottle |
Singleton | Public — throttle hook (default: no-op). Replace to add rate-limiting. |
IClaimsEnricher |
Singleton | Public — claims enrichment hook (default: no-op). Add implementations to inject extra claims into JWTs. |
| JWT Bearer authentication | — | JwtBearerDefaults.AuthenticationScheme configured with your signing key |
| Authorization | — | services.AddAuthorization() |
The generic TContext parameter tells the library which DbContext to resolve from DI.
The library uses DbContext.Set<TEntity>() internally — your DbContext does not need
DbSet<> properties for the identity tables.
What MapNivoraIdentityEndpoints() does
Maps a route group at /auth with twenty endpoints (see API Endpoints).
The /auth/me, /auth/request-email-confirmation, /auth/change-password,
/auth/set-phone, /auth/request-phone-verification, /auth/confirm-phone,
/auth/external-login/link, /auth/external-login/unlink,
/auth/2fa/totp/setup, /auth/2fa/totp/enable, and /auth/2fa/totp/disable endpoints
require a valid Bearer token; all others are anonymous.
What AddNivoraIdentitySchema() does
Applies EF Core entity configurations for IdentityUser, RefreshToken,
PasswordResetToken, EmailConfirmationToken, PhoneVerificationToken,
IdentityRole, IdentityUserRole,
IdentityExternalLogin, and TwoFactorChallenge.
Creates tables nivora_identity_users, nivora_identity_refresh_tokens,
nivora_identity_password_reset_tokens, nivora_identity_email_confirmation_tokens,
nivora_identity_phone_verification_tokens,
nivora_identity_roles, nivora_identity_user_roles,
nivora_identity_external_logins, and nivora_identity_two_factor_challenges.
Using INivoraIdentityFacade (no HTTP)
For same-process UI, background jobs, or integration tests that need to call identity
operations without going through HTTP, inject INivoraIdentityFacade:
using Nivora.Identity.Abstractions;
using Nivora.Identity.Contracts.Dtos;
public class OnboardingService(INivoraIdentityFacade identity)
{
public async Task<AuthResponse> OnboardUserAsync(string email, string password)
{
var ctx = IdentityCallContext.Internal(correlationId: "onboarding");
var tokens = await identity.RegisterAsync(
new RegisterRequest(email, password), ctx);
return tokens;
}
}
The facade runs the same validation, auditing, and throttle pipeline as the HTTP
endpoints. Known failures throw IdentityOperationException with StatusCode and
Detail properties that map 1-to-1 with the HTTP ProblemDetails responses:
try
{
var tokens = await identity.LoginAsync(
new LoginRequest(email, password),
IdentityCallContext.Internal());
}
catch (IdentityOperationException ex) when (ex.StatusCode == 401)
{
// Handle invalid credentials
}
Custom Table Names
By default, Nivora.Identity creates tables named nivora_identity_users and
nivora_identity_refresh_tokens. You can override these names (and optionally the
schema) per table.
Why the host must pass table names in OnModelCreating
EF Core builds its model during OnModelCreating, which runs before the DI
container is fully available. Because of this, IOptions<T> or other DI services
cannot be injected into the model-building phase. The host must pass table name
overrides explicitly when calling AddNivoraIdentitySchema.
Option A — appsettings.json + lambda
You can store table names in configuration and read them during startup:
// appsettings.json
{
"NivoraIdentity": {
"SigningKey": "your-secret-key-at-least-32-characters-long",
"UsersTableName": "myapp_users",
"RefreshTokensTableName": "myapp_refresh_tokens",
"Schema": "auth"
}
}
Then in your DbContext:
using Nivora.Identity.Persistence;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddNivoraIdentitySchema(o =>
{
o.Schema = "auth";
o.UsersTableName = "myapp_users";
o.RefreshTokensTableName = "myapp_refresh_tokens";
});
}
}
Option B — Use defaults
If you don't need custom table names, the parameterless overload keeps the default behavior:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddNivoraIdentitySchema();
}
NivoraIdentitySchemaOptions reference
| Property | Type | Default | Description |
|---|---|---|---|
UsersTableName |
string |
"nivora_identity_users" |
Table name for identity users |
RefreshTokensTableName |
string |
"nivora_identity_refresh_tokens" |
Table name for refresh tokens |
PasswordResetTokensTableName |
string |
"nivora_identity_password_reset_tokens" |
Table name for password reset tokens |
RolesTableName |
string |
"nivora_identity_roles" |
Table name for roles |
UserRolesTableName |
string |
"nivora_identity_user_roles" |
Table name for user-role assignments |
EmailConfirmationTokensTableName |
string |
"nivora_identity_email_confirmation_tokens" |
Table name for email confirmation tokens |
ExternalLoginsTableName |
string |
"nivora_identity_external_logins" |
Table name for external login links |
PhoneVerificationTokensTableName |
string |
"nivora_identity_phone_verification_tokens" |
Table name for phone verification tokens |
TwoFactorChallengesTableName |
string |
"nivora_identity_two_factor_challenges" |
Table name for two-factor challenge tokens |
Schema |
string? |
null |
Database schema (uses provider default when null) |
API Endpoints
All endpoints live under the /auth prefix and return ProblemDetails on errors.
POST /auth/register
Register a new user and receive tokens immediately.
Request:
{ "email": "user@example.com", "password": "MySecureP@ss1" }
Success (200):
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "dGVzdC1yZWZyZXNoLXRva2Vu...",
"expiresIn": 900
}
Error (409):
{ "detail": "A user with this email already exists.", "status": 409 }
POST /auth/login
Request:
{ "email": "user@example.com", "password": "MySecureP@ss1" }
Success (200) — no 2FA: Same shape as register (AuthResponse).
Success (200) — 2FA enabled: If the user has TOTP two-factor authentication enabled,
the endpoint returns a TwoFactorRequiredResponse instead of AuthResponse:
{
"challengeToken": "base64url-encoded-challenge-token",
"expiresInSeconds": 300
}
⚠️ Backward compatibility: Clients must inspect the response shape. If the response contains
challengeToken, the client must complete the login viaPOST /auth/login-2fa. If it containsaccessToken, the login is complete.
Error (401):
{ "detail": "Invalid email or password.", "status": 401 }
Security: The error message is identical whether the email doesn't exist, the password is wrong, the account is disabled, or the account is locked out. This prevents email enumeration attacks.
POST /auth/refresh
Rotate the refresh token. The old token is revoked; a new pair is returned.
Request:
{ "refreshToken": "dGVzdC1yZWZyZXNoLXRva2Vu..." }
Success (200): New AuthResponse.
Error (401):
{ "detail": "Invalid or expired refresh token.", "status": 401 }
POST /auth/logout
Revoke a refresh token. Always returns 204 No Content (does not leak whether the token was valid).
Request:
{ "refreshToken": "dGVzdC1yZWZyZXNoLXRva2Vu..." }
GET /auth/me (requires Bearer token)
Returns the current user's profile.
Headers: Authorization: Bearer <access_token>
Success (200):
{
"id": "a1b2c3d4-...",
"email": "user@example.com",
"createdAt": "2025-01-15T10:30:00+00:00",
"lastLoginAt": "2025-06-01T14:22:00+00:00",
"emailConfirmedAt": "2025-01-15T10:35:00+00:00",
"phoneNumber": "+15551234567",
"phoneConfirmedAt": "2025-06-01T15:00:00+00:00"
}
POST /auth/request-email-confirmation (requires Bearer token)
Requests an email confirmation token for the authenticated user. Always returns a
generic 200 response. The token is created internally but never returned over HTTP.
The host application must use IIdentityAdminService.CreateEmailConfirmationTokenAsync(email)
to obtain the raw token and email it to the user (see Email Confirmation Flow).
Headers: Authorization: Bearer <access_token>
Response (200) — always:
{ "message": "If applicable, a confirmation email was sent." }
POST /auth/confirm-email
Confirms a user's email address using a valid confirmation token. On success, the user's
EmailConfirmedAt timestamp is set and the token is marked as used (single-use).
Request:
{ "token": "base64url-encoded-confirmation-token" }
Success: 204 No Content
Error (400):
{ "detail": "Invalid or expired email confirmation token.", "status": 400 }
POST /auth/forgot-password
Initiates a password reset. Always returns the same generic 200 response regardless
of whether the email exists, preventing email enumeration.
Request:
{ "email": "user@example.com" }
Response (200) — always:
{ "message": "If an account exists, password reset instructions were sent." }
Note: This endpoint does not send emails or return the reset token. The host application must call
IIdentityAdminService.CreatePasswordResetTokenAsync(email)to obtain the raw token and email it to the user (see Password Reset Flow).
POST /auth/reset-password
Resets the user's password using a valid reset token. On success, the password is updated and all existing refresh tokens for that user are revoked (logout everywhere).
Request:
{ "token": "base64url-encoded-reset-token", "newPassword": "MyNewP@ss1" }
Success: 204 No Content
Error (400):
{ "detail": "Invalid or expired reset token.", "status": 400 }
POST /auth/change-password (requires Bearer token)
Changes the authenticated user's password. On success, the password is updated and all existing refresh tokens for that user are revoked (logout everywhere). The user must re-authenticate with the new password.
Headers: Authorization: Bearer <access_token>
Request:
{ "currentPassword": "MyOldP@ss1", "newPassword": "MyNewP@ss1" }
Success: 204 No Content
Error (400):
{ "detail": "Invalid current password.", "status": 400 }
POST /auth/external-login
Authenticate or register a user via a host-verified external identity. The host application is responsible for validating the external provider's token (e.g. Google, Microsoft, GitHub) before calling this endpoint. Nivora.Identity does NOT perform OAuth flows or token validation — it trusts the data you send.
Request:
{ "provider": "Google", "providerUserId": "1234567890", "email": "user@example.com" }
email is optional. If provided and a user with that email already exists, the
external identity is linked to that user. If no user exists and
AllowExternalLoginAutoCreate is true, a new user is created with the email marked
as confirmed (the library assumes the external provider verified the email).
Success (200):
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "dGVzdC1yZWZyZXNoLXRva2Vu...",
"expiresIn": 900
}
Error (400):
{ "detail": "External login is not linked.", "status": 400 }
POST /auth/external-login/link (requires Bearer token)
Link an external provider identity to the authenticated user. Idempotent — linking the same provider identity to the same user a second time succeeds silently.
Headers: Authorization: Bearer <access_token>
Request:
{ "provider": "Microsoft", "providerUserId": "ms-uid-001" }
Success: 204 No Content
Error (400):
{ "detail": "This external identity is already linked to another account.", "status": 400 }
POST /auth/external-login/unlink (requires Bearer token)
Remove all external login links for the authenticated user and the specified provider.
No-op if no matching links exist. Returns 400 if unlinking would leave an
external-only user (no password set) with no sign-in method.
Headers: Authorization: Bearer <access_token>
Request:
{ "provider": "Google" }
Success: 204 No Content
POST /auth/set-phone (requires Bearer token)
Sets the phone number for the authenticated user. If the phone number changes,
PhoneConfirmedAt is reset to null.
Headers: Authorization: Bearer <access_token>
Request:
{ "phoneNumber": "+15551234567" }
Success: 204 No Content
POST /auth/request-phone-verification (requires Bearer token)
Sets the phone number for the authenticated user (if changed) and resets
PhoneConfirmedAt. Does not create a verification code — the host must call
IIdentityAdminService.CreatePhoneVerificationCodeAsync(userId) to obtain the code
and send it via SMS. Always returns a generic 200 response.
Headers: Authorization: Bearer <access_token>
Request:
{ "phoneNumber": "+15551234567" }
Response (200) — always:
{ "message": "If applicable, a verification code was sent." }
POST /auth/confirm-phone (requires Bearer token)
Confirms the authenticated user's phone number using a valid verification code.
On success, PhoneConfirmedAt is set and the token is marked as used (single-use).
Headers: Authorization: Bearer <access_token>
Request:
{ "token": "123456" }
Success: 204 No Content
Error (400):
{ "detail": "Invalid or expired phone verification token.", "status": 400 }
POST /auth/2fa/totp/setup (requires Bearer token)
Generates a new TOTP secret for the authenticated user and returns the Base32-encoded
secret and an otpauth:// URI suitable for QR code generation. This does not enable
2FA — the user must verify a code via /auth/2fa/totp/enable first.
Headers: Authorization: Bearer <access_token>
Success (200):
{
"secretBase32": "JBSWY3DPEHPK3PXP...",
"otpAuthUri": "otpauth://totp/Nivora:user%40example.com?secret=JBSWY3DPEHPK3PXP...&issuer=Nivora&digits=6&period=30"
}
Error (400):
{ "detail": "User not found.", "status": 400 }
POST /auth/2fa/totp/enable (requires Bearer token)
Verifies a TOTP code against the previously set-up secret and enables two-factor authentication for the user.
Headers: Authorization: Bearer <access_token>
Request:
{ "code": "123456" }
Success: 204 No Content
Error (400):
{ "detail": "Invalid TOTP code.", "status": 400 }
POST /auth/2fa/totp/disable (requires Bearer token)
Disables TOTP two-factor authentication. Requires a valid TOTP code to confirm the user has access to their authenticator. The TOTP secret is cleared from the database.
Headers: Authorization: Bearer <access_token>
Request:
{ "code": "123456" }
Success: 204 No Content
Error (400):
{ "detail": "Invalid TOTP code.", "status": 400 }
POST /auth/login-2fa
Completes a two-factor authentication login using the challenge token from
POST /auth/login and a valid TOTP code.
Request:
{
"challengeToken": "base64url-encoded-challenge-token",
"code": "123456"
}
Success (200):
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "dGVzdC1yZWZyZXNoLXRva2Vu...",
"expiresIn": 900
}
Error (401):
{ "detail": "Invalid or expired challenge token.", "status": 401 }
Password Reset Flow
Nivora.Identity does not send emails. The library provides the building blocks; your host application handles the actual delivery.
How it works
- User requests a password reset (e.g., clicks "Forgot password?" in your UI).
- Your host calls
IIdentityAdminService.CreatePasswordResetTokenAsync(email).- If the user exists and is not disabled, this returns a raw reset token string.
- If the user does not exist or is disabled, it returns
null.
- Your host emails the token to the user as part of a reset link
(e.g.,
https://yourapp.com/reset-password?token={token}). - User clicks the link and submits the new password.
- Your frontend calls
POST /auth/reset-passwordwith{ "token": "...", "newPassword": "..." }. - On success, the password is updated, the token is marked as used (single-use), and all refresh tokens are revoked.
Security notes
- The raw token is never returned over HTTP by any endpoint.
- Only a SHA-256 hash of the token is stored in the database.
- Tokens are single-use — once consumed, they cannot be reused.
- Tokens expire after
PasswordResetTokenMinutes(default: 15 minutes). /auth/forgot-passwordalways returns the same200response to prevent email enumeration.
Example host integration
app.MapPost("/api/forgot-password", async (
ForgotPasswordRequest request,
IIdentityAdminService admin,
IEmailSender emailSender) =>
{
var token = await admin.CreatePasswordResetTokenAsync(request.Email);
if (token is not null)
{
var resetLink = $"https://yourapp.com/reset-password?token={token}";
await emailSender.SendAsync(request.Email, "Reset your password", resetLink);
}
// Always return the same response to prevent email enumeration
return Results.Ok(new { message = "If an account exists, password reset instructions were sent." });
});
Email Confirmation Flow
Nivora.Identity does not send emails. The library provides the building blocks; your host application handles the actual delivery.
How it works
- User registers (email is not auto-confirmed).
- Your host calls
IIdentityAdminService.CreateEmailConfirmationTokenAsync(email).- If the user exists, is not disabled, and email is not yet confirmed, this returns a raw confirmation token string.
- Otherwise it returns
null.
- Your host emails the token to the user as part of a confirmation link
(e.g.,
https://yourapp.com/confirm-email?token={token}). - User clicks the link.
- Your frontend calls
POST /auth/confirm-emailwith{ "token": "..." }. - On success,
EmailConfirmedAtis set on the user and the token is marked as used (single-use).
Security notes
- The raw token is never returned over HTTP by any endpoint.
- Only a SHA-256 hash of the token is stored in the database.
- Tokens are single-use — once consumed, they cannot be reused.
- Tokens expire after
EmailConfirmationTokenMinutes(default: 60 minutes). POST /auth/request-email-confirmationalways returns the same200response (token is created internally but not returned).- If a user is already confirmed,
CreateEmailConfirmationTokenAsyncreturnsnulland the confirm endpoint is idempotent (does not change an existingEmailConfirmedAt).
Example host integration
app.MapPost("/api/request-email-confirmation", async (
ForgotPasswordRequest request,
IIdentityAdminService admin,
IEmailSender emailSender) =>
{
var token = await admin.CreateEmailConfirmationTokenAsync(request.Email);
if (token is not null)
{
var confirmLink = $"https://yourapp.com/confirm-email?token={token}";
await emailSender.SendAsync(request.Email, "Confirm your email", confirmLink);
}
// Always return the same response to prevent email enumeration
return Results.Ok(new { message = "If applicable, a confirmation email was sent." });
});
Phone Verification Flow
Nivora.Identity does not send SMS messages. The library provides the building blocks; your host application handles the actual SMS delivery.
How it works
- User sets their phone number via
POST /auth/set-phoneorPOST /auth/request-phone-verification(both require a Bearer token). - Your host calls
IIdentityAdminService.CreatePhoneVerificationCodeAsync(userId).- If the user exists, is not disabled, and has a phone number set, this returns
a raw numeric verification code string (e.g.,
"482901"). - Otherwise it returns
null.
- If the user exists, is not disabled, and has a phone number set, this returns
a raw numeric verification code string (e.g.,
- Your host sends the code to the user via SMS using your preferred provider.
- User enters the code in your UI.
- Your frontend calls
POST /auth/confirm-phonewith{ "token": "482901" }(requires a Bearer token). - On success,
PhoneConfirmedAtis set on the user and the token is marked as used (single-use).
Security notes
- The raw code is never returned over HTTP by any endpoint.
- Only a SHA-256 hash of the code is stored in the database.
- Codes are single-use — once consumed, they cannot be reused.
- Codes expire after
PhoneVerificationTokenMinutes(default: 10 minutes). - Codes are numeric with a configurable length (
PhoneVerificationCodeLength, default: 6). POST /auth/confirm-phonerequires a Bearer token, binding the confirmation to the authenticated user to prevent token theft.- Changing the phone number automatically resets
PhoneConfirmedAttonull.
Example host integration
app.MapPost("/api/send-phone-verification", async (
ClaimsPrincipal user,
IIdentityAdminService admin,
ISmsSender smsSender) =>
{
var userId = Guid.Parse(user.FindFirstValue("sub")!);
var code = await admin.CreatePhoneVerificationCodeAsync(userId);
if (code is not null)
{
// Your host sends the SMS — Nivora.Identity never does
await smsSender.SendAsync("+15551234567", $"Your verification code: {code}");
}
return Results.Ok(new { message = "If applicable, a verification code was sent." });
});
TOTP Two-Factor Authentication
Nivora.Identity supports TOTP-based two-factor authentication (RFC 6238) using
authenticator apps such as Google Authenticator, Microsoft Authenticator, or Authy.
No external packages are used — the TOTP implementation uses built-in System.Security.Cryptography.
How it works
Setup: Authenticated user calls
POST /auth/2fa/totp/setup.- The server generates a random 20-byte secret, encrypts it with ASP.NET Core Data Protection, and stores it in the user record.
- Returns the Base32-encoded secret and an
otpauth://URI. The host can generate a QR code from the URI for the user to scan. - 2FA is not enabled yet at this point.
Enable: User scans the QR code (or enters the secret manually) in their authenticator app, then sends the current 6-digit code to
POST /auth/2fa/totp/enable.- The server verifies the code against the stored secret (with ±1 time-step drift).
- On success,
TwoFactorEnabledis set totrue.
Login with 2FA: When a user with 2FA enabled calls
POST /auth/login:- Credentials are validated as usual (lockout, disabled, etc.).
- Instead of returning
AuthResponse, the endpoint returns aTwoFactorRequiredResponsecontaining achallengeTokenandexpiresInSeconds. - Only a SHA-256 hash of the challenge token is stored in the database.
Complete 2FA login: The client sends the challenge token and a TOTP code to
POST /auth/login-2fa.- The server validates the challenge (hash match, not used, not expired) and the TOTP code.
- On success, the challenge is marked as used (single-use) and
AuthResponsetokens are issued.
Disable: Authenticated user calls
POST /auth/2fa/totp/disablewith a valid TOTP code. The secret is cleared from the database andTwoFactorEnabledis set tofalse.
Security notes
- TOTP secrets are encrypted at rest using ASP.NET Core Data Protection
(purpose:
"Nivora.Identity.TotpSecret"). No hardcoded encryption keys. - Challenge tokens are hashed (SHA-256) before storage — the raw token is only returned to the client.
- Challenges are single-use and expire after
TwoFactorChallengeMinutes(default: 5 minutes). - TOTP verification allows ±1 time-step drift (30-second steps) to account for clock skew between server and client.
- The TOTP secret is cleared when 2FA is disabled.
- No QR code generation is included — the host can generate QR codes from the
otpauth://URI using any QR library.
Configuration
builder.Services.AddNivoraIdentity<AppDbContext>(opts =>
{
opts.SigningKey = "...";
opts.Issuer = "MyApp"; // used in otpauth:// URI
opts.TwoFactorChallengeMinutes = 5; // default: 5
});
Example client flow
1. GET /auth/me → check current state
2. POST /auth/2fa/totp/setup → { secretBase32, otpAuthUri }
3. User scans QR code in authenticator app
4. POST /auth/2fa/totp/enable { code: "123456" } → 204
5. POST /auth/login { email, password }
→ { challengeToken, expiresInSeconds }
6. POST /auth/login-2fa { challengeToken, code: "654321" }
→ { accessToken, refreshToken, expiresIn }
Backward compatibility
The POST /auth/login endpoint now returns either AuthResponse or
TwoFactorRequiredResponse depending on whether the user has 2FA enabled.
Clients must inspect the response shape:
- If the response contains
accessToken→ login is complete. - If the response contains
challengeToken→ the client must complete login viaPOST /auth/login-2fa.
The INivoraIdentityFacade.LoginAsync method returns Task<LoginResult>.
Callers can pattern-match the result:
LoginSuccess— containsTokens(AuthResponse)LoginTwoFactorRequired— containsChallengeTokenandExpiresInSeconds
Admin / Provisioning Service
Inject IIdentityAdminService to seed users or perform administrative operations
without exposing token issuance:
using Nivora.Identity.Abstractions;
using Nivora.Identity.Contracts.Dtos;
public class SeedService(IIdentityAdminService admin)
{
public async Task SeedAdminUserAsync()
{
var existing = await admin.FindByEmailAsync("admin@example.com");
if (existing is not null) return;
UserInfo user = await admin.CreateUserAsync(
"admin@example.com", "Admin123!", confirmEmail: true);
Console.WriteLine($"Seeded admin user {user.Id}");
}
}
IIdentityAdminService methods
| Method | Description |
|---|---|
CreateUserAsync(email, password, confirmEmail) |
Creates a new user. Returns UserInfo. |
DisableUserAsync(userId, reason) |
Sets IsDisabled = true. |
SetPasswordAsync(userId, newPassword, reason) |
Re-hashes and stores a new password. |
RevokeAllSessionsAsync(userId, reason) |
Revokes all active refresh tokens. |
FindByEmailAsync(email) |
Returns UserInfo? by normalized email lookup. |
CreatePasswordResetTokenAsync(email) |
Returns the raw reset token string, or null if user not found/disabled. |
CreateEmailConfirmationTokenAsync(email) |
Returns the raw confirmation token string, or null if user not found/disabled/already confirmed. |
CreateRoleAsync(roleName) |
Creates a role (idempotent; normalized to upper-invariant). |
DeleteRoleAsync(roleName) |
Deletes a role and removes all user assignments. No-op if not found. |
AssignRoleAsync(userId, roleName) |
Assigns a role to a user. Auto-creates the role if it doesn't exist. Idempotent. |
RemoveRoleAsync(userId, roleName) |
Removes a role assignment from a user. No-op if not assigned. |
GetUserRolesAsync(userId) |
Returns all role names assigned to the user. |
GetAllRolesAsync() |
Returns all role names, ordered alphabetically. |
CreatePhoneVerificationCodeAsync(userId) |
Returns the raw numeric verification code, or null if user not found/disabled/no phone set. |
Roles
Nivora.Identity supports role-based authorization. Roles are managed through
IIdentityAdminService and embedded in JWT access tokens as "role" claims.
How roles appear in JWT
When a user logs in or refreshes their token, all assigned roles are included as
individual "role" claims in the JWT payload:
{
"sub": "a1b2c3d4-...",
"email": "admin@example.com",
"role": ["Admin", "Editor"],
"jti": "...",
"exp": 1234567890
}
The library configures RoleClaimType = "role" in TokenValidationParameters,
so ASP.NET Core's [Authorize(Roles = "...")] works out of the box.
Seeding roles
Use IIdentityAdminService to create roles and assign them to users at startup
or from admin endpoints:
public class SeedService(IIdentityAdminService admin)
{
public async Task SeedAsync()
{
// Create a role explicitly (idempotent)
await admin.CreateRoleAsync("Admin");
// Find or create user, then assign the role
var user = await admin.FindByEmailAsync("admin@example.com");
if (user is not null)
{
// AssignRoleAsync auto-creates the role if it doesn't exist
await admin.AssignRoleAsync(user.Id, "Admin");
}
}
}
Protecting endpoints
app.MapGet("/admin/dashboard", () => Results.Ok("Admin area"))
.RequireAuthorization(new AuthorizeAttribute { Roles = "Admin" });
Or in controllers:
[Authorize(Roles = "Admin")]
public class AdminController : ControllerBase { }
Multiple roles (any-of):
[Authorize(Roles = "Admin,Editor")]
public IActionResult Edit() => Ok();
Important notes
- Role names are normalized (trimmed + upper-invariant) for uniqueness checks,
but the original casing is preserved in the
Namecolumn and JWT claims. - Changing a user's roles does not invalidate existing access tokens. The new roles take effect on the next login or token refresh.
AssignRoleAsyncauto-creates the role if it doesn't exist, making it safe for simple use cases. UseCreateRoleAsyncfor explicit role seeding.DeleteRoleAsyncremoves the role and all user assignments.
External Login (Host-Verified)
Nivora.Identity supports provider-agnostic external login for scenarios like Google, Microsoft, or GitHub sign-in. The library does NOT implement any OAuth flows or validate provider tokens. Your host application must:
- Perform the OAuth / OpenID Connect flow with the external provider.
- Validate the provider's token and extract the verified user identity
(
ProviderUserIdand optionallyEmail). - Call the Nivora.Identity external login endpoint (or facade method) with the verified data.
⚠️ Security: Never forward unvalidated external tokens to Nivora.Identity. The library trusts the
Provider,ProviderUserId, and
How it works
- User authenticates with an external provider (Google, Microsoft, GitHub, etc.) in your host application.
- Your host validates the provider's token and extracts the user's
ProviderUserIdandEmail. - Your host calls
POST /auth/external-login(orINivoraIdentityFacade.ExternalLoginAsync) with the verified data. - Nivora.Identity:
- Looks up an existing external login link by
(Provider, ProviderUserId). - If found: issues tokens for the linked user (if not disabled).
- If not found and
Emailis provided: looks up a user by email and links to them, or creates a new user ifAllowExternalLoginAutoCreateistrue. - The provider name is normalized to lowercase for storage and comparison.
- When a new user is auto-created,
EmailConfirmedAtis set to the current time (the library assumes the external provider verified the email).
- Looks up an existing external login link by
⚠️ Important: Only pass
email_verifiedclaim). If the provider does not confirm verification, omit
- Auto-created users have an empty
PasswordHash, so password-based login is not possible for them unless a password is explicitly set viaIIdentityAdminService.SetPasswordAsync.
Configuration
builder.Services.AddNivoraIdentity<AppDbContext>(opts =>
{
opts.SigningKey = "...";
opts.AllowExternalLoginAutoCreate = true; // default: true
});
Set AllowExternalLoginAutoCreate = false to require that external identities are
pre-linked (via /auth/external-login/link) before they can be used for login.
Example host integration (Google)
app.MapPost("/api/auth/google", async (
GoogleTokenRequest request,
IGoogleTokenValidator google,
INivoraIdentityFacade identity,
HttpContext httpContext) =>
{
// 1. Validate Google's ID token (host responsibility)
var payload = await google.ValidateAsync(request.IdToken);
// 2. Call Nivora.Identity with verified data
var ctx = IdentityCallContext.FromHttp(httpContext);
var response = await identity.ExternalLoginAsync(
new ExternalLoginRequest("Google", payload.Subject, payload.Email), ctx);
return Results.Ok(response);
});
Linking and unlinking (authenticated)
Once a user is logged in, they can link additional external identities or unlink existing ones:
// Link
var ctx = IdentityCallContext.FromHttp(httpContext);
await identity.LinkExternalLoginAsync(userId,
new ExternalLoginLinkRequest("GitHub", ghUserId), ctx);
// Unlink
await identity.UnlinkExternalLoginAsync(userId,
new ExternalLoginUnlinkRequest("GitHub"), ctx);
Schema table: nivora_identity_external_logins
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
UserId |
GUID |
FK → users (cascade delete), indexed |
Provider |
string(50) |
Lowercase-normalized provider name |
ProviderUserId |
string(256) |
Provider-specific user identifier |
CreatedAt |
DateTimeOffset |
Indexes: Unique index on (Provider, ProviderUserId), index on UserId.
Claims Enrichment
Nivora.Identity supports injecting custom claims into JWT access tokens via the
IClaimsEnricher hook. Multiple enrichers can be registered and are all invoked
during token generation (login, refresh, and 2FA completion).
How it works
- Implement
IClaimsEnricher:
using System.Security.Claims;
using Nivora.Identity.Abstractions;
public class TenantClaimsEnricher(TenantService tenants) : IClaimsEnricher
{
public async Task<IReadOnlyList<Claim>> GetClaimsAsync(
Guid userId, CancellationToken ct = default)
{
var tenantId = await tenants.GetTenantIdAsync(userId, ct);
return tenantId is null
? []
: [new Claim("tenant_id", tenantId)];
}
}
- Register it in DI (after
AddNivoraIdentity):
builder.Services.AddSingleton<IClaimsEnricher, TenantClaimsEnricher>();
Multiple enrichers are supported — all registered IClaimsEnricher implementations
are invoked and their claims are aggregated.
Reserved claims
The following claim types are reserved and will be silently ignored if returned by an enricher:
sub, jti, exp, iat, nbf, iss, aud, email, role
This prevents enrichers from accidentally overriding security-critical claims.
Keyless Projection Models
If your host application queries the identity tables directly for admin UI or
reporting, you may define a keyless projection/DTO type and map it with
FromSqlRaw or FromSqlInterpolated.
Important: You must configure such types with HasNoKey() and ToView(null)
in OnModelCreating. Without this, EF Core migrations will attempt to create an
extra table for the projection type.
HasNoKey()marks the entity as keyless (no primary key tracked).ToView(null)tells EF Core this type is not backed by any table or view, so migrations will not generate aCREATE TABLEfor it.
Example
// A read-only projection for admin listing
public sealed class IdentityUserRow
{
public Guid Id { get; set; }
public string Email { get; set; } = null!;
public bool IsDisabled { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastLoginAt { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddNivoraIdentitySchema(o =>
{
o.Schema = "auth";
o.UsersTableName = "myapp_users";
o.RefreshTokensTableName = "myapp_refresh_tokens";
});
// Keyless projection — prevents migrations from creating an extra table
modelBuilder.Entity<IdentityUserRow>()
.HasNoKey()
.ToView(null);
}
You can then query it:
var users = await db.Set<IdentityUserRow>()
.FromSqlRaw("SELECT Id, Email, IsDisabled, CreatedAt, LastLoginAt FROM auth.myapp_users")
.ToListAsync();
Public Surface
The library intentionally keeps a minimal public API:
| Type | Namespace |
|---|---|
NivoraIdentityOptions |
Nivora.Identity.Options |
NivoraIdentitySchemaOptions |
Nivora.Identity.Options |
AddNivoraIdentity<TContext>() |
Microsoft.Extensions.DependencyInjection |
MapNivoraIdentityEndpoints() |
Nivora.Identity.Endpoints |
AddNivoraIdentitySchema() |
Nivora.Identity.Persistence |
AddNivoraIdentitySchema(Action<NivoraIdentitySchemaOptions>) |
Nivora.Identity.Persistence |
INivoraIdentityFacade |
Nivora.Identity.Abstractions |
IIdentityAdminService |
Nivora.Identity.Abstractions |
IIdentityThrottle |
Nivora.Identity.Abstractions |
IClaimsEnricher |
Nivora.Identity.Abstractions |
IdentityCallContext |
Nivora.Identity.Abstractions |
CallerKind |
Nivora.Identity.Abstractions |
IdentityOperationException |
Nivora.Identity.Abstractions |
RegisterRequest / LoginRequest / RefreshRequest / LogoutRequest |
Nivora.Identity.Contracts.Dtos |
ForgotPasswordRequest / ResetPasswordRequest / ConfirmEmailRequest / ChangePasswordRequest |
Nivora.Identity.Contracts.Dtos |
SetPhoneRequest / ConfirmPhoneRequest |
Nivora.Identity.Contracts.Dtos |
ExternalLoginRequest / ExternalLoginLinkRequest / ExternalLoginUnlinkRequest |
Nivora.Identity.Contracts.Dtos |
TotpSetupResponse / TotpEnableRequest / TotpDisableRequest |
Nivora.Identity.Contracts.Dtos |
TwoFactorRequiredResponse / Login2FaRequest |
Nivora.Identity.Contracts.Dtos |
LoginResult / LoginSuccess / LoginTwoFactorRequired |
Nivora.Identity.Contracts.Dtos |
AuthResponse / MeResponse / UserInfo |
Nivora.Identity.Contracts.Dtos |
Everything else (services, domain entities, utilities) is internal.
Database Schema
Nine tables are created by default (names are customisable — see Custom Table Names):
nivora_identity_users
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
Email |
string(256) |
Original casing |
NormalizedEmail |
string(256) |
Unique index, upper-invariant |
PasswordHash |
string |
ASP.NET Core Identity v3 hash |
IsDisabled |
bool |
|
AccessFailedCount |
int |
Resets on successful login or lockout |
LockoutEnd |
DateTimeOffset? |
Auto-set after max failed attempts |
CreatedAt |
DateTimeOffset |
|
LastLoginAt |
DateTimeOffset? |
|
EmailConfirmedAt |
DateTimeOffset? |
Set when email is confirmed |
PhoneNumber |
string?(32) |
User's phone number |
PhoneConfirmedAt |
DateTimeOffset? |
Set when phone is confirmed |
TwoFactorEnabled |
bool |
Whether TOTP 2FA is enabled |
TotpSecretEncrypted |
string?(512) |
Data Protection–encrypted TOTP secret |
TwoFactorEnabledAt |
DateTimeOffset? |
When 2FA was enabled |
nivora_identity_refresh_tokens
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
UserId |
GUID |
FK → users, indexed |
TokenHash |
string(128) |
SHA-256 of the raw token, indexed |
CreatedAt |
DateTimeOffset |
|
ExpiresAt |
DateTimeOffset |
|
RevokedAt |
DateTimeOffset? |
Set on logout, refresh rotation, or admin revoke |
ReplacedByTokenHash |
string?(128) |
Points to the successor token for audit |
nivora_identity_password_reset_tokens
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
UserId |
GUID |
FK → users, indexed |
TokenHash |
string(128) |
SHA-256 of the raw token, unique index |
CreatedAt |
DateTimeOffset |
|
ExpiresAt |
DateTimeOffset |
Default: 15 minutes from creation |
UsedAt |
DateTimeOffset? |
Set when the token is consumed (single-use) |
nivora_identity_email_confirmation_tokens
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
UserId |
GUID |
FK → users, indexed |
TokenHash |
string(128) |
SHA-256 of the raw token, unique index |
CreatedAt |
DateTimeOffset |
|
ExpiresAt |
DateTimeOffset |
Default: 60 minutes from creation |
UsedAt |
DateTimeOffset? |
Set when the token is consumed (single-use) |
nivora_identity_roles
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
Name |
string(256) |
Original casing |
NormalizedName |
string(256) |
Unique index, upper-invariant |
CreatedAt |
DateTimeOffset |
nivora_identity_user_roles
| Column | Type | Notes |
|---|---|---|
UserId |
GUID |
Composite PK, FK → users (cascade delete) |
RoleId |
GUID |
Composite PK, FK → roles (cascade delete) |
nivora_identity_external_logins
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
UserId |
GUID |
FK → users (cascade delete), indexed |
Provider |
string(50) |
Lowercase-normalized provider name |
ProviderUserId |
string(256) |
Provider-specific user identifier |
CreatedAt |
DateTimeOffset |
Indexes: Unique composite index on (Provider, ProviderUserId).
nivora_identity_phone_verification_tokens
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
UserId |
GUID |
FK → users (cascade delete), indexed |
PhoneNumber |
string(32) |
Phone number at time of request, indexed |
TokenHash |
string(128) |
SHA-256 of the raw code, unique index |
CreatedAt |
DateTimeOffset |
|
ExpiresAt |
DateTimeOffset |
Default: 10 minutes from creation |
UsedAt |
DateTimeOffset? |
Set when the code is consumed (single-use) |
nivora_identity_two_factor_challenges
| Column | Type | Notes |
|---|---|---|
Id |
GUID |
PK |
UserId |
GUID |
FK → users (cascade delete), indexed |
ChallengeHash |
string(128) |
SHA-256 of the raw challenge token, unique index |
CreatedAt |
DateTimeOffset |
|
ExpiresAt |
DateTimeOffset |
Default: 5 minutes from creation |
UsedAt |
DateTimeOffset? |
Set when the challenge is consumed (single-use) |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.2)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.