Nivora.Identity 0.1.12

dotnet add package Nivora.Identity --version 0.1.12
                    
NuGet\Install-Package Nivora.Identity -Version 0.1.12
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Nivora.Identity" Version="0.1.12" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Nivora.Identity" Version="0.1.12" />
                    
Directory.Packages.props
<PackageReference Include="Nivora.Identity" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Nivora.Identity --version 0.1.12
                    
#r "nuget: Nivora.Identity, 0.1.12"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Nivora.Identity@0.1.12
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Nivora.Identity&version=0.1.12
                    
Install as a Cake Addin
#tool nuget:?package=Nivora.Identity&version=0.1.12
                    
Install as a Cake Tool

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

  1. Installation
  2. Quick Start
  3. Configuration
  4. Host App Integration
  5. Custom Table Names
  6. API Endpoints
  7. Password Reset Flow
  8. Email Confirmation Flow
  9. Phone Verification Flow
  10. TOTP Two-Factor Authentication
  11. Admin / Provisioning Service
  12. Roles
  13. External Login (Host-Verified)
  14. Claims Enrichment
  15. Keyless Projection Models
  16. Public Surface
  17. Database Schema
  18. 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.JwtBearer
  • Microsoft.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 via POST /auth/login-2fa. If it contains accessToken, 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

  1. User requests a password reset (e.g., clicks "Forgot password?" in your UI).
  2. 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.
  3. Your host emails the token to the user as part of a reset link (e.g., https://yourapp.com/reset-password?token={token}).
  4. User clicks the link and submits the new password.
  5. Your frontend calls POST /auth/reset-password with { "token": "...", "newPassword": "..." }.
  6. 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-password always returns the same 200 response 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

  1. User registers (email is not auto-confirmed).
  2. 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.
  3. Your host emails the token to the user as part of a confirmation link (e.g., https://yourapp.com/confirm-email?token={token}).
  4. User clicks the link.
  5. Your frontend calls POST /auth/confirm-email with { "token": "..." }.
  6. On success, EmailConfirmedAt is 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-confirmation always returns the same 200 response (token is created internally but not returned).
  • If a user is already confirmed, CreateEmailConfirmationTokenAsync returns null and the confirm endpoint is idempotent (does not change an existing EmailConfirmedAt).

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

  1. User sets their phone number via POST /auth/set-phone or POST /auth/request-phone-verification (both require a Bearer token).
  2. 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.
  3. Your host sends the code to the user via SMS using your preferred provider.
  4. User enters the code in your UI.
  5. Your frontend calls POST /auth/confirm-phone with { "token": "482901" } (requires a Bearer token).
  6. On success, PhoneConfirmedAt is 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-phone requires a Bearer token, binding the confirmation to the authenticated user to prevent token theft.
  • Changing the phone number automatically resets PhoneConfirmedAt to null.

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

  1. 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.
  2. 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, TwoFactorEnabled is set to true.
  3. 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 a TwoFactorRequiredResponse containing a challengeToken and expiresInSeconds.
    • Only a SHA-256 hash of the challenge token is stored in the database.
  4. 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 AuthResponse tokens are issued.
  5. Disable: Authenticated user calls POST /auth/2fa/totp/disable with a valid TOTP code. The secret is cleared from the database and TwoFactorEnabled is set to false.

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 via POST /auth/login-2fa.

The INivoraIdentityFacade.LoginAsync method returns Task<LoginResult>. Callers can pattern-match the result:

  • LoginSuccess — contains Tokens (AuthResponse)
  • LoginTwoFactorRequired — contains ChallengeToken and ExpiresInSeconds

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 Name column 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.
  • AssignRoleAsync auto-creates the role if it doesn't exist, making it safe for simple use cases. Use CreateRoleAsync for explicit role seeding.
  • DeleteRoleAsync removes 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:

  1. Perform the OAuth / OpenID Connect flow with the external provider.
  2. Validate the provider's token and extract the verified user identity (ProviderUserId and optionally Email).
  3. 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 Email values you provide.

How it works

  1. User authenticates with an external provider (Google, Microsoft, GitHub, etc.) in your host application.
  2. Your host validates the provider's token and extracts the user's ProviderUserId and Email.
  3. Your host calls POST /auth/external-login (or INivoraIdentityFacade.ExternalLoginAsync) with the verified data.
  4. 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 Email is provided: looks up a user by email and links to them, or creates a new user if AllowExternalLoginAutoCreate is true.
    • The provider name is normalized to lowercase for storage and comparison.
    • When a new user is auto-created, EmailConfirmedAt is set to the current time (the library assumes the external provider verified the email).

⚠️ Important: Only pass email from providers that indicate the address is verified (e.g., Google's email_verified claim). If the provider does not confirm verification, omit email or verify it yourself before calling this endpoint.

  • Auto-created users have an empty PasswordHash, so password-based login is not possible for them unless a password is explicitly set via IIdentityAdminService.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

  1. 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)];
    }
}
  1. 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 a CREATE TABLE for 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.12 153 2/8/2026
0.1.11 117 2/8/2026
0.1.10 113 2/8/2026
0.1.9 114 2/8/2026
0.1.8 118 2/8/2026
0.1.7 118 2/8/2026
0.1.6 120 2/8/2026
0.1.5 116 2/8/2026
0.1.4 122 2/8/2026
0.1.3 119 2/8/2026
0.1.2 120 2/8/2026
0.1.1 122 2/8/2026
0.1.0 124 2/8/2026