Pinqponq.Identity.Otp
1.0.1
Prefix Reserved
dotnet add package Pinqponq.Identity.Otp --version 1.0.1
NuGet\Install-Package Pinqponq.Identity.Otp -Version 1.0.1
<PackageReference Include="Pinqponq.Identity.Otp" Version="1.0.1" />
<PackageVersion Include="Pinqponq.Identity.Otp" Version="1.0.1" />
<PackageReference Include="Pinqponq.Identity.Otp" />
paket add Pinqponq.Identity.Otp --version 1.0.1
#r "nuget: Pinqponq.Identity.Otp, 1.0.1"
#:package Pinqponq.Identity.Otp@1.0.1
#addin nuget:?package=Pinqponq.Identity.Otp&version=1.0.1
#tool nuget:?package=Pinqponq.Identity.Otp&version=1.0.1
Pinqponq.Identity.Otp
One-time code send/verify flow over email or SMS. Channel routing (mail/sms)
lives inside this package; the storage interface for pending codes is left to
the consuming application, matching the interface-only approach used by
Pinqponq.Identity's refresh tokens.
Install
dotnet add package Pinqponq.Identity.Otp
Requirements
- .NET 8.0, 9.0, or 10.0
- An
IOtpStoreimplementation from your application (Redis, EF Core, …) — required Pinqponq.Sms(AddPinqponqSms) if you send codes over SMSPinqponq.Mail(AddPinqponqMail) if you send codes over email- A
HashPeppersecret of at least 32 characters
Quick start
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Pinqponq.Identity.Otp;
using Pinqponq.Identity.Otp.DependencyInjection;
using Pinqponq.Mail.DependencyInjection; // AddPinqponqMail
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPinqponqOtp(options =>
{
options.CodeLength = 6;
options.Ttl = TimeSpan.FromMinutes(3);
options.MaxAttempts = 5;
options.MinSendInterval = TimeSpan.FromSeconds(30);
options.HashPepper = builder.Configuration["Otp:HashPepper"]!; // >= 32 chars
options.EmailSubjectTemplate = "Your {0}-digit verification code";
options.EmailBodyTemplate = "Your verification code is {0}. It expires in 3 minutes.";
});
// A sender is only required for the channel this application actually uses.
builder.Services.AddPinqponqMail(mail => builder.Configuration.GetSection("Smtp").Bind(mail));
// Application-owned persistence for pending OTP records (see "Main types" below).
builder.Services.AddScoped<IOtpStore, MyOtpStore>();
var app = builder.Build();
app.MapPost("/otp/send", async (
SendOtpRequest request,
IOtpService otpService,
CancellationToken cancellationToken) =>
{
// channel defaults to OtpChannel.Auto — email if the recipient contains '@', SMS otherwise.
await otpService.GenerateAndSendAsync(
recipient: request.Email,
channel: OtpChannel.Email,
purpose: "login",
cancellationToken: cancellationToken);
return Results.Accepted();
});
app.MapPost("/otp/verify", async (
VerifyOtpRequest request,
IOtpService otpService,
CancellationToken cancellationToken) =>
{
var status = await otpService.VerifyAsync(
recipient: request.Email,
code: request.Code,
purpose: "login",
cancellationToken: cancellationToken);
return status switch
{
OtpVerifyStatus.Success => Results.Ok(),
OtpVerifyStatus.Expired => Results.BadRequest("Code expired, request a new one."),
OtpVerifyStatus.TooManyAttempts => Results.BadRequest("Too many attempts."),
_ => Results.BadRequest("Invalid code."),
};
});
app.Run();
Configuration
OtpOptions (configured via AddPinqponqOtp, validated on startup). Message
templates use {0} as the code placeholder.
| Option | Default | Notes |
|---|---|---|
CodeLength |
6 | Must be between 4 and 12. |
Ttl |
180 seconds | Must be positive. Code lifetime. |
MaxAttempts |
5 | Must be greater than zero. |
MinSendInterval |
30 seconds | Passed to IOtpSendRateLimiter. Must be >= 0. The default limiter is a no-op — see "Notes / behavior". |
HashPepper |
— | Required. Must be at least 32 characters. Mixed into the code hash via HMAC-SHA256. |
SmsTemplate |
"Your verification code: {0}" |
SMS body. |
EmailSubjectTemplate |
"Your verification code: {0}" |
Email subject. |
EmailBodyTemplate |
"Your verification code: {0}" |
Email body. |
Main types
| Type | Lifetime | Description |
|---|---|---|
IOtpService |
Scoped | GenerateAndSendAsync(recipient, channel, purpose, ct), VerifyAsync(recipient, code, purpose, ct). Requires the application to register IOtpStore. |
IOtpStore |
App-provided | SaveAsync, FindAsync, UpdateAsync, RemoveAsync, TryRemoveAsync, TryConsumeAsync. |
IOtpSendRateLimiter |
Singleton (AllowAllOtpSendRateLimiter by default — no-op) |
TryAcquireAsync(key, minInterval, ct). Replace with a real limiter (e.g. Redis-backed) to enforce MinSendInterval. |
OtpChannel |
Enum | Auto (email if recipient contains @, else SMS), Sms, Email. |
OtpVerifyStatus |
Enum | Success, NotFound, Expired, TooManyAttempts, Mismatch. |
OtpRecord |
Model | Key, CodeHash, Recipient, ExpiresAt, Attempts, CreatedAt. The raw code is never persisted. |
OtpSendRateLimitedException |
Exception | Thrown by GenerateAndSendAsync when IOtpSendRateLimiter.TryAcquireAsync returns false. |
IOtpStore.TryConsumeAsync is the core contract implementations must get
right — it performs expiry check, attempt-limit check, hash comparison,
remove-on-success and attempts-increment-on-mismatch atomically:
public sealed class MyOtpStore : IOtpStore
{
private readonly ConcurrentDictionary<string, OtpRecord> _records = new();
private readonly object _gate = new();
public Task SaveAsync(OtpRecord record, CancellationToken ct = default)
{
_records[record.Key] = record;
return Task.CompletedTask;
}
public Task<OtpRecord?> FindAsync(string key, CancellationToken ct = default) =>
Task.FromResult(_records.GetValueOrDefault(key));
public Task UpdateAsync(OtpRecord record, CancellationToken ct = default)
{
_records[record.Key] = record;
return Task.CompletedTask;
}
public Task RemoveAsync(string key, CancellationToken ct = default)
{
_records.TryRemove(key, out _);
return Task.CompletedTask;
}
public Task<bool> TryRemoveAsync(string key, string expectedCodeHash, CancellationToken ct = default)
{
lock (_gate)
{
if (_records.TryGetValue(key, out var record) && record.CodeHash == expectedCodeHash)
{
return Task.FromResult(_records.TryRemove(key, out _));
}
return Task.FromResult(false);
}
}
public Task<OtpVerifyStatus> TryConsumeAsync(
string key, string codeHash, int maxAttempts, DateTimeOffset utcNow, CancellationToken ct = default)
{
lock (_gate)
{
if (!_records.TryGetValue(key, out var record))
{
return Task.FromResult(OtpVerifyStatus.NotFound);
}
if (utcNow >= record.ExpiresAt)
{
_records.TryRemove(key, out _);
return Task.FromResult(OtpVerifyStatus.Expired);
}
if (record.Attempts >= maxAttempts)
{
return Task.FromResult(OtpVerifyStatus.TooManyAttempts);
}
if (record.CodeHash != codeHash)
{
record.Attempts++;
return Task.FromResult(OtpVerifyStatus.Mismatch);
}
_records.TryRemove(key, out _);
return Task.FromResult(OtpVerifyStatus.Success);
}
}
}
Notes / behavior
- Only the code's hash is ever persisted (
OtpRecord.CodeHash, via HMAC-SHA256 keyed withHashPepper) — the raw code exists only in memory long enough to be sent. - Channel routing lives in this package.
OtpChannel.Autopicks email when the recipient contains@, otherwise SMS. A sender is resolved lazily when a code is actually sent, so an application that only sends OTPs by email does not need to callAddPinqponqSms— the sender is registered viaservices.TryAddScoped<IOtpService>(...)withISmsSender?/IEmailSender?as optional dependencies; whichever one is missing only throws when a send is routed to that channel, naming the registration to add (AddPinqponqSms/AddPinqponqMail). IOtpStoreis required at first resolve, not at registration.IOtpServiceis registered through a factory so ASP.NET Core's container validation in Development does not abort startup for an app that has not yet wired a store; the missingIOtpStoreis reported as anInvalidOperationExceptionthe first timeIOtpServiceis resolved.- Rate limiting is opt-in. The default
IOtpSendRateLimiteris a no-op that always allows the send — register your own (Redis, in-memory, …) to actually enforceMinSendIntervaland throwOtpSendRateLimitedExceptionon abuse. - Delivery failure rolls back the code. If
GenerateAndSendAsyncfails to deliver, the record is removed via the compare-and-setTryRemoveAsync(matching onCodeHash) so a concurrent, newerGenerateAndSendAsynccall for the same recipient/purpose is never accidentally deleted. - Target frameworks:
net8.0,net9.0,net10.0.
Related packages
- Pinqponq.Sms — SMS sending (
ISmsSender), needed when routing codes to the SMS channel. - Pinqponq.Mail — SMTP mail sending
(
IEmailSender), needed when routing codes to the email channel. - Pinqponq.Identity — JWT/refresh-token/ password primitives, typically issued right after a successful OTP verification.
- Pinqponq.Auth.Totp — an alternative 2FA factor (RFC 6238 TOTP) that does not require sending anything.
Samples
Try this package in the browser via Pinqponq.Playground — see samples/README.md.
Repository
| 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 is compatible. 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 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.Extensions.Configuration (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Http (>= 10.0.10)
- Microsoft.Extensions.Logging (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Options (>= 10.0.10)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.10)
- Pinqponq.Mail (>= 1.0.1)
- Pinqponq.Sms (>= 1.0.1)
- Polly.Core (>= 8.4.2)
-
net8.0
- Microsoft.Extensions.Configuration (>= 8.0.0)
- Microsoft.Extensions.DependencyInjection (>= 8.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Logging (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.3)
- Microsoft.Extensions.Options (>= 8.0.2)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Pinqponq.Mail (>= 1.0.1)
- Pinqponq.Sms (>= 1.0.1)
- Polly.Core (>= 8.4.2)
-
net9.0
- Microsoft.Extensions.Configuration (>= 9.0.18)
- Microsoft.Extensions.DependencyInjection (>= 9.0.18)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.18)
- Microsoft.Extensions.Http (>= 9.0.18)
- Microsoft.Extensions.Logging (>= 9.0.18)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.18)
- Microsoft.Extensions.Options (>= 9.0.18)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 9.0.18)
- Pinqponq.Mail (>= 1.0.1)
- Pinqponq.Sms (>= 1.0.1)
- Polly.Core (>= 8.4.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.