Omni2FA.AspNetCore
0.8.0
dotnet add package Omni2FA.AspNetCore --version 0.8.0
NuGet\Install-Package Omni2FA.AspNetCore -Version 0.8.0
<PackageReference Include="Omni2FA.AspNetCore" Version="0.8.0" />
<PackageVersion Include="Omni2FA.AspNetCore" Version="0.8.0" />
<PackageReference Include="Omni2FA.AspNetCore" />
paket add Omni2FA.AspNetCore --version 0.8.0
#r "nuget: Omni2FA.AspNetCore, 0.8.0"
#:package Omni2FA.AspNetCore@0.8.0
#addin nuget:?package=Omni2FA.AspNetCore&version=0.8.0
#tool nuget:?package=Omni2FA.AspNetCore&version=0.8.0
Omni2FA
Drop-in multi-method two-factor authentication for self-hosted apps — TOTP, Email OTP, WebAuthn/passkeys, recovery codes — behind one OpenAPI contract.
You verify the password and mint your session; Omni2FA handles everything 2FA in between — enrollment, the login challenge, OTP/WebAuthn ceremonies, recovery codes, persistence, rate limiting, audit. Backend service + endpoints + EF store on .NET; headless hooks on React. Other stacks implement the same contract (roadmap below).
For apps that own their user table (ASP.NET Core Identity, custom JWT/cookie auth, …). Not for managed identity providers (Auth0, Clerk, Cognito, Firebase, Supabase, Okta, WorkOS) — they already ship 2FA.
Packages
| Stack | Install | Notes |
|---|---|---|
| .NET (ASP.NET Core) | Omni2FA.AspNetCore + Omni2FA.AspNetCore.EntityFrameworkCore |
Omni2FA.Core comes transitively |
| React | @omni2fa/core + @omni2fa/react |
hooks; styled MUI dialogs (@omni2fa/react-mui) — planned |
Other backends (Node, Python) and frontends (Angular, Vue) are on the roadmap; any stack can implement the OpenAPI contract.
Backend — ASP.NET Core
dotnet add package Omni2FA.AspNetCore
dotnet add package Omni2FA.AspNetCore.EntityFrameworkCore
Program.cs
builder.Services.AddOmni2Fa(o => builder.Configuration.GetSection("Omni2Fa").Bind(o));
builder.Services.AddOmni2FaEntityFrameworkStore<AppDbContext>(); // or implement the store interfaces yourself
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapOmni2Fa(); // mounts /api/2fa/*
Your DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyOmni2FaConfiguration(); // 2FA tables (names configurable)
}
Hook the pre-auth token into your existing login — the seam between your password check and the 2FA step:
public class AuthService(IPreAuthTokenIssuer preAuth, ITwoFactorMethodStore methods) {
// after you verify the password:
if (await methods.HasActiveMethodsAsync(userId)) {
var ticket = preAuth.Issue(userId); // short-lived JWT
var available = await methods.ListActiveByUserAsync(userId);
return Challenge(ticket.Token, ticket.ExpiresAt, available); // your DTO → frontend
}
// else: mint your session as usual
// when the frontend finishes 2FA, it calls your "finalize" with the verifiedToken from the verify response:
var verifiedUserId = preAuth.ValidateVerified(verifiedToken); // null if not actually verified → reject
}
Use the DTOs the library already ships at the host seam — don't reinvent them (all in Omni2FA.Core.Dtos / .Services):
| DTO | Where you use it |
|---|---|
PreAuthTokenInfo |
returned by Issue / IssueVerified — { Token, ExpiresAt } |
PreAuthChallengeResponse |
your login response when 2FA is required — { PreAuthToken, AvailableMethods, ExpiresAt } |
TwoFactorMethodDto |
shape of an enrolled method (AvailableMethods items) |
VerifySuccessResponse |
the /challenge/verify body — { UserId, VerifiedToken, ExpiresAt } |
ErrorResponse + Omni2FaErrorCodes |
error envelope and the stable error-code constants |
appsettings.json
"Omni2Fa": {
"PreAuth": { "SigningKey": "<32+ char HMAC key — from env/secrets>" },
"Totp": { "Issuer": "MyApp" },
"Email": { "FromAddress": "no-reply@myapp.com",
"Smtp": { "Host": "smtp.example.com", "Port": 587, "Username": "...", "Password": "...", "UseStartTls": true } },
"WebAuthn": { "RelyingPartyId": "myapp.com", "Origins": [ "https://myapp.com" ] },
"StepUp": { "RequireTwoFactorToEnroll": true, "RequireTwoFactorToRemoveMethod": true, "RequireTwoFactorToRegenerateRecoveryCodes": true } // opt-in (default false): step-up on Omni2FA's own destructive endpoints
}
That's the whole backend: endpoints, the three methods, recovery codes, rate limiting and audit are live.
Frontend — React
npm i @omni2fa/core @omni2fa/react
Once, at the app root
import { createOmni2Fa } from '@omni2fa/core';
import { Omni2FaProvider } from '@omni2fa/react';
export const omni = createOmni2Fa({ baseUrl: '/api/2fa' });
// after your login, hand the client your host session token (or use cookies: createOmni2Fa({ credentials: 'include' })):
omni.client.setSessionToken(sessionToken);
<Omni2FaProvider value={omni}>{children}</Omni2FaProvider>
The hooks expose state + actions; you render the UI (headless).
Login challenge
import { useChallenge } from '@omni2fa/react';
const { status, context, pick, submit, useRecoveryCode } = useChallenge();
// pick(methodId) → submit(code) (WebAuthn auto-runs the browser ceremony)
// status: 'idle' | 'awaitingCode' | 'asserting' | 'verifying' | 'verified' | 'failed' | …
// on 'verified': send context.verifiedToken to your finalize endpoint (not the pre-auth token)
Hooks: useMethods, useTotpEnrollment, useEmailEnrollment, useWebAuthnEnrollment, useChallenge (+ *Selector variants). A full headless UI you can copy lives in examples/full/frontend. Styled drop-in components (@omni2fa/react-mui) are planned.
Step-up — confirm sensitive actions
Force a fresh 2FA check right before a sensitive action (change password, view recovery codes, remove a method). Decorate the endpoint: if the user has 2FA enrolled they must confirm it; if they don't, the call passes through. A stolen session alone can't perform the action.
Backend — one attribute on an MVC action, or .RequireStepUp() on a minimal-API endpoint:
[HttpPost("change-password")]
[RequireTwoFactor] // → 403 STEP_UP_REQUIRED until a valid step-up token is sent
public Task<IActionResult> ChangePassword(ChangePasswordRequest req) { … }
// minimal API:
app.MapPost("/account/email", ChangeEmail).RequireAuthorization().RequireStepUp();
Frontend — useStepUp() gives you confirmTwoFactor(methods): it shows the 2FA prompt and resolves a single-use token (or null if cancelled). You attach that token in the X-Omni2FA-StepUp header on your request — over fetch or axios, cookie or Bearer session; the library never touches your transport. Two ways to use it:
Reactive — let the server tell you. Best in one central place (an axios/fetch interceptor, right next to your 401 handling) — covers every protected endpoint at once:
import { STEP_UP_HEADER, Omni2FaErrorCodes } from '@omni2fa/core';
// in your response interceptor, when a call returns 403:
const err = await res.clone().json();
if (err.code === Omni2FaErrorCodes.StepUpRequired) {
const token = await confirmTwoFactor(err.details.availableMethods);
if (token) res = await replayRequest({ [STEP_UP_HEADER]: token }); // retry with the header
}
Proactive — when you already know an action needs 2FA, confirm up-front and skip the 403 round-trip. You supply the methods yourself (e.g. from useMethods()):
const { confirmTwoFactor } = useStepUp();
const { items: methods } = useMethods();
async function changePassword() {
let headers = {};
if (methods.length > 0) { // no 2FA enrolled → nothing to confirm, server lets it through
const token = await confirmTwoFactor(methods);
if (!token) return; // cancelled
headers = { [STEP_UP_HEADER]: token };
}
await api.changePassword(body, headers); // sent already carrying the token
}
While a prompt is active, render the 2FA UI (reuse your challenge UI): methods → pick(id) → submit(code).
Protecting the library's own endpoints — remove method, regenerate recovery codes, and enroll a new factor are mounted by MapOmni2Fa, so you can't decorate them. Turn them on with the per-action StepUp.RequireTwoFactorTo* flags (in appsettings.json above; all off by default — and recovery codes can't be viewed, only regenerated, so that's the gated action). Then register the prompt once so the client handles those 403s itself (no per-call wiring):
const { confirmTwoFactor /* + prompt state to render */ } = useStepUp();
useEffect(() => omni.client.setStepUpHandler(confirmTwoFactor), [confirmTwoFactor]);
Now omni.client.removeMethod(...) / regenerateRecoveryCodes() / enrollment prompt for 2FA and retry automatically. A user with no method enrolled is never blocked.
The step-up token is single-use — one confirmed 2FA per protected action. Consumed token ids are kept in memory by default, so on a multi-instance deployment a token spent on one node isn't known to the others — a brief replay window within the token TTL. Register a shared IStepUpNonceStore (e.g. Redis) to close it. Details in docs/ARCHITECTURE.md.
Configuration (key options, under Omni2Fa)
| Option | Default | Purpose |
|---|---|---|
PreAuth.SigningKey |
— (required, ≥32 chars) | HMAC key for the pre-auth ticket; validated at startup |
PreAuth.Ttl |
5 min | Pre-auth ticket lifetime |
PreAuth.VerifiedTtl |
2 min | Verified-handoff token lifetime (the finalize proof) |
StepUp.Ttl |
5 min | Step-up token lifetime — gap allowed between confirming 2FA and the action |
StepUp.RequireTwoFactorTo{Enroll,RemoveMethod,RegenerateRecoveryCodes} |
false |
Gate the library's own destructive endpoints with step-up (opt-in, per action) |
Totp.Issuer |
Omni2FA |
Name shown in authenticator apps |
Email.Smtp.* / Email.BackgroundDelivery |
— / true |
SMTP transport; codes sent on a background worker by default |
WebAuthn.RelyingPartyId / Origins |
localhost / http://localhost:5173 |
Must match your real hostname (HTTPS off-localhost) |
WebAuthn.MaxCredentialsPerUser |
3 | Passkey cap per user |
RateLimit.{PermitLimit,Window} |
20 / 1 min | Per-IP limit on verify/enroll endpoints |
AspNetCore.AllowDisablingLastMethod |
true |
Set false to forbid removing the last method |
| EF table/column names | Omni2Fa* |
Override via ApplyOmni2FaConfiguration(o => …) for migrations |
Extension points (swap any piece — TryAdd, host wins)
| Interface | Replace to… |
|---|---|
IEmailSender |
send via your own infra (SendGrid/SES/relay) instead of MailKit/SMTP |
IEmailMessageBuilder |
customize/localize the OTP email copy |
IOmni2FaAuditSink |
forward audit events to your log/SIEM (default → ILogger) |
ITwoFactorMethodStore / ITwoFactorChallengeStore / IRecoveryCodeStore |
use Mongo/Dapper/raw ADO instead of EF Core |
IUserContextAccessor |
derive the current user id from a custom claim/header |
IStepUpNonceStore |
share single-use step-up token ids across instances (Redis/DB) — default is in-memory |
IPreAuthTokenIssuer |
change how the pre-auth ticket is minted/validated |
IWebAuthnCeremonyService |
swap the FIDO2 implementation |
Methods & status
| Method | State |
|---|---|
| TOTP (authenticator apps) | ✅ |
| Email OTP (SMTP) | ✅ |
| WebAuthn (passkeys / security keys) | ✅ |
| Recovery codes (one-time, hashed) | ✅ |
| Rate limiting · audit sink | ✅ |
🚧 Status — pre-1.0 (0.6.x). Functionally complete for .NET + React, verified by a live end-to-end run, but young: no automated test suite yet (planned for v1.0), and WebAuthn/email not yet battle-tested across many environments. Suitable for your own apps; harden before betting a production product on it.
More
examples/full— runnable ASP.NET + React + SQLite app with all methods.docs/ARCHITECTURE.md·docs/FLOWS.md·docs/ROADMAP.md·docs/PUBLISHING.mdCore/protocol/omni2fa.openapi.yaml— the cross-stack contract.
License
MIT — see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. 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. |
-
net8.0
- Fido2 (>= 4.0.1)
- MailKit (>= 4.16.0)
- Omni2FA.Core (>= 0.8.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.