Skip to content
fullstackhero

Concept

Authentication

JWT bearer with rotating refresh tokens, ASP.NET Identity user store, lockout, email confirmation, and the auth-flow rate-limit policy.

views 0 Last updated

The Identity module owns authentication. Login goes through POST /api/v1/identity/tokens/generate and returns an access token (default 60 minutes) and a refresh token (default 14 days). Both are signed by Jwt:SigningKey; the refresh token is stored hashed and rotated on every refresh to limit replay damage if it leaks. Both endpoints (and every other auth-flow endpoint) are throttled by the kit’s auth rate-limit policy.

What ships

  • JWT bearer via Microsoft.AspNetCore.Authentication.JwtBearer 10.0.8.
  • ASP.NET Identity on top of FshUser : IdentityUser and FshRole : IdentityRole.
  • Refresh tokens stored hashed in FshUser.RefreshToken; rotated on each /tokens/refresh.
  • Lockout — 5 failed attempts triggers 15-minute lockout (IdentityOptions defaults).
  • Password policy — 12-character minimum, history (last 5 blocked), 90-day expiry with 14-day warning, customisable in PasswordPolicy:*.
  • Email confirmation — new users start with EmailConfirmed = false and cannot mint tokens until the confirm-email flow completes.
  • Two-factor TOTP — opt-in per user; once enrolled, tokens/generate requires the TOTP code (see two-factor authentication).
  • Rate-limited endpoints — login, refresh, confirm-email, forgot-password, reset-password, self-register all carry the auth policy.

Configuration

{
"Jwt": {
"Issuer": "fullstackhero",
"Audience": "fullstackhero-api",
"AccessTokenMinutes": 60,
"RefreshTokenDays": 14,
"SigningKey": "set-via-secrets-manager-or-env-var"
},
"PasswordPolicy": {
"PasswordHistoryCount": 5,
"PasswordExpiryDays": 90,
"PasswordExpiryWarningDays": 14,
"EnforcePasswordExpiry": true
}
}

For Jwt:SigningKey, use 32+ random bytes encoded in base64:

Terminal window
openssl rand -base64 32

Store it in your platform’s secrets system (Azure Key Vault, AWS Secrets Manager, GitHub Secrets, Doppler, etc.) and inject via environment variable (Jwt__SigningKey with double underscore).

The token pipeline

POST /tokens/generate
└─ rate-limited (auth policy)
└─ UserManager.CheckPasswordAsync (with lockout)
└─ 2FA check (if enrolled)
└─ Email-confirmed check
└─ ITokenService.IssueAsync → JWT access + refresh
└─ Open a UserSession (per-device tracking)
└─ Publish TokenGeneratedIntegrationEvent (outbox)
└─ Return TokenResponse

The access token carries the standard claims plus:

  • sub / uid — the user id
  • tenant — the resolved tenant at login time
  • permissions — the user’s effective permission set (role + group aggregated)

Permission enforcement on downstream endpoints reads the permissions claim directly — no DB round-trip per request.

Refresh token rotation

POST /tokens/refresh validates the refresh token, mints a fresh access and refresh token, invalidates the old refresh token (clears the stored hash), and returns the new pair. If the same refresh token is presented twice, the second attempt fails with 401 — a signal that either replay-after-rotation happened, or your client is misconfigured (sending the same token in parallel).

public sealed class RefreshTokenCommandHandler(IFshUserManager users, ITokenService tokens)
: ICommandHandler<RefreshTokenCommand, RefreshTokenCommandResponse>
{
public async ValueTask<RefreshTokenCommandResponse> Handle(RefreshTokenCommand cmd, CancellationToken ct)
{
var user = await users.FindByValidRefreshTokenAsync(cmd.RefreshToken, ct)
?? throw new UnauthorizedException("Refresh token invalid or expired.");
var pair = await tokens.IssueAsync(user, ct).ConfigureAwait(false);
// IssueAsync rotates user.RefreshToken inside the same transaction
return new RefreshTokenCommandResponse(pair.AccessToken, pair.RefreshToken, pair.ExpiresInSeconds);
}
}

Email confirmation flow

Newly registered users have EmailConfirmed = false. Token generation rejects them with 401:

1. POST /users/self-register → user created, EmailConfirmed = false, confirm-email link mailed
2. User clicks link → POST /users/confirm-email → EmailConfirmed = true
3. User can now POST /tokens/generate → access + refresh

In integration tests, the kit uses a helper to flip EmailConfirmed = true directly via UserManager.UpdateAsync (mirroring the pattern in Tests/Users/EmailConfirmationTests.cs).

Lockout and brute-force defence

Lockout is configured by IdentityOptions. The kit’s defaults:

  • Lockout after 5 consecutive failed attempts.
  • Lockout duration: 15 minutes.
  • Lockout applies to login attempts only; successful logins reset the counter.

This stacks with the rate-limit auth policy — rate limiting throttles abusive callers regardless of identity; lockout protects individual user accounts even from low-rate but persistent attackers.

What authentication doesn’t do

  • Single sign-on (SSO). No OIDC / SAML / OAuth provider integration in v10. JWT bearer is the only token type. SSO is the most-requested roadmap item; if you need it now, layer an external IdP (Keycloak, Azure AD B2C, Auth0) in front and accept the IdP’s JWTs.
  • Passwordless / magic links. No built-in passwordless flow. The forgot-password flow uses a token-via-email; you could repurpose it but it’s not a first-class passwordless mode.
  • WebAuthn / passkeys. The kit has an AspNetUserPasskeys table from ASP.NET Identity but no end-to-end passkey-only flow. Passkey support is on the roadmap.