Authentication
Dual auth (Admin + User), multi-workspace routing, JWT tokens, 2FA with backup codes, password expiry enforcement, SSO suspension gate, and tenant-scoped password policy.
SCRIPE provides a secure authentication system with JWT access tokens, refresh token rotation, optional two-factor authentication, multi-workspace login discovery, password expiry enforcement, and comprehensive rate limiting. The system supports separate Admin and User authentication flows with different JWT claims and permissions.
Authentication Flow
Two-Factor Authentication (Deep)
Multi-Workspace Login Discovery
When an admin logs in from the platform domain (no pre-resolved tenant), the backend executes a 3-case routing algorithm. Case A uses a provided tenantId for strict domain isolation. Case A' triggers when the isPlatformAdmin flag is set — this directly looks up the platform admin (TenantId = null) and bypasses workspace discovery entirely, preventing an infinite loop. Case B performs workspace discovery: it first checks for a platform admin, then searches all tenants by email — returning a workspace picker if multiple matches are found.
| Case | Condition | Behavior |
|---|---|---|
| Case A — Tenant-Scoped | tenantId is provided | Strict domain isolation. Decrypts TenantId and queries exclusively for the admin in that tenant. |
| Case A' — Platform Admin | isPlatformAdmin = true | Bypasses workspace discovery. Queries for TenantId = null (platform level) to prevent workspace picker infinite loop. |
| Case B — Discovery | No tenantId, not isPlatformAdmin | Step 1: Check for platform admin. Step 2: Search all tenants by email. 0 matches -> invalid, 1 match -> direct login, N matches -> return workspace selection list. |
OIDC/SSO Callback & Workspace Selection
For Single Sign-On (OIDC/SAML) callbacks, the system resolves the email and checks if multiple workspaces are linked. If there are multiple candidate workspaces, a temporary workspace selection token is generated and stored in a cache under `sso-login-selection:{token}`. The user is redirected to choose a workspace, which then dispatches `CompleteOidcWorkspaceSelectionCommand` to decrypt the selected tenant ID and issue final JWTs.
// Temporary selection cache setup in OIDC Callback:
var token = Guid.NewGuid().ToString("N");
await _cache.SetAsync($"sso-login-selection:{token}", new SsoTempLoginData
{
Email = email,
Provider = provider
}, TimeSpan.FromMinutes(10));
// Complete Workspace Selection command handler:
var loginData = await _cache.GetAsync<SsoTempLoginData>($"sso-login-selection:{request.Token}");
var targetTenantId = _idEncryption.Decrypt(request.TenantId);
var admin = allMatchingAdmins.FirstOrDefault(a => a.IsActive && a.TenantId == targetTenantId);Password Expiry Enforcement
During login, after BCrypt verification and lockout checks, the handler invokes ITenantPasswordValidator to check if the admin's password has exceeded the tenant's PasswordExpiryDays setting. If expired, the response includes MustChangePassword = true, forcing the frontend to redirect the admin to the change-password page. The admin receives a valid JWT but cannot access the dashboard until the password is updated.
MustChangePassword Middleware & Whitelist
When a password expires, the user's JWT receives the `mcp: true` claim. The `MustChangePasswordMiddleware` blocks all incoming requests with 403 Forbidden unless the request matches the whitelist: password change endpoint `/api/v1/admins/{adminId}/change-password`, profile details `/api/v1/auth/admin/me`, token refresh `/api/v1/auth/admin/refresh`, session logouts `/api/v1/auth/admin/logout` or `/api/v1/auth/admin/revoke`, and the system `/health` endpoint.
public async Task InvokeAsync(HttpContext context)
{
if (context.User.Identity?.IsAuthenticated == true)
{
var mcpClaim = context.User.FindFirst("mcp")?.Value;
if (mcpClaim == "true")
{
var path = context.Request.Path.Value ?? "";
var isAllowed = AllowedPatterns.Any(pattern => pattern.IsMatch(path));
if (!isAllowed)
{
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync("{\"error\":\"Password change required.\",\"code\":\"MustChangePassword\"}");
return;
}
}
}
await _next(context);
}SSO Tenant Suspension Gate
The ExternalLoginCommandHandler includes a tenant suspension security gate. After SSO token validation and account linking, the handler checks the admin's tenant status. If the tenant is Suspended or Cancelled, login is rejected with a localized error — preventing deactivated users from bypassing standard login checks via SSO providers like Google or Azure AD.
JWT Token Configuration
The system uses short-lived access tokens (15 minutes) with long-lived refresh tokens (7 days). Refresh tokens are rotated on each use to prevent reuse attacks.
| Token Type | Lifetime | Storage | Rotation / Purpose |
|---|---|---|---|
| Access Token (JWT) | 15 minutes | Memory / Cookie | Refreshed automatically |
| Refresh Token | 7 days | HttpOnly Cookie | Rotated on each use (one-time use) |
| 2FA Temporary Token | 5 minutes | Memory | Discarded after 2FA verification |
Token Validation & Claim Checks
Both authentication pipelines share the same JWT token validation process. The middleware verifies name identifier claims, checks the 'admin' flag, and queries the database bypassing global query filters using `IgnoreQueryFilters()` to verify the admin or user account is still active and has not been soft-deleted.
OnTokenValidated = async context =>
{
var idClaim = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var isAdmin = context.Principal?.FindFirst("admin")?.Value == "true";
if (string.IsNullOrEmpty(idClaim) || !Guid.TryParse(idClaim, out var id))
{
context.Fail("Invalid token: missing or invalid ID claim");
return;
}
using var scope = context.HttpContext.RequestServices.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
if (isAdmin)
{
var admin = await dbContext.Admins
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted, context.HttpContext.RequestAborted);
if (admin == null || !admin.IsActive)
{
context.Fail("Admin no longer exists or is inactive");
}
}
else
{
var user = await dbContext.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Id == id && !u.IsDeleted, context.HttpContext.RequestAborted);
if (user == null || !user.IsActive)
{
context.Fail("User no longer exists or is inactive");
}
}
}Dual Authentication (Admin & User)
SCRIPE has two separate authentication pipelines. Admin auth (AdminAuthController) issues JWTs with admin-specific claims (TenantId, IsSuperAdmin, Roles). User auth (UserAuthController) issues JWTs with user-specific claims (NationalId, Gender, Country). Each has its own login, register, and token refresh endpoints.
| Dimension | Admin Authentication | User (Client) Authentication |
|---|---|---|
| API Route Prefix | /api/v1/auth/admin | /api/v1/auth/user |
| Controller File | AdminAuthController.cs | UserAuthController.cs |
| Primary Command | AdminLoginCommand | UserLoginCommand |
| Domain Entity | Admin | User |
| Token Claims | Sub (Id), Username, TenantId, HierarchyPath, MCP | Sub (Id), Username, TenantId |
| Permission Scheme | Server-side cache IAdminPermissionCache | Lean JWT (implied tenant scopes, no cache) |
| 2FA Support | Yes (TOTP + Backup Codes) | Yes (TOTP + Backup Codes) |
| Lockout Policy | 5 attempts / 15 min lockout | 5 attempts / 15 min lockout |
Admin Entity (Security Features)
The Admin entity has several security-critical fields that control account behavior and protection.
| Field | Type | Purpose |
|---|---|---|
| IsSuperAdmin | bool | System-level access, bypasses tenant scoping |
| IsProtected | bool | Cannot be deleted, deactivated, or demoted (Guardian enforced) |
| IsLastSuperAdminInTenant | bool | Safety flag; blocks deletion/deactivation if true |
| PasswordLastChanged | DateTime? | Checked against TenantSettings.PasswordExpiryDays for rotation |
| UsernameLastChanged | DateTime? | 30-day cooldown on username changes |
| IsTwoFactorEnabled | bool | Whether 2FA is active for this admin |
| TwoFactorSecret | string? | Secret key for TOTP generation |
| BackupCodesJson | string? | JSON array of hashed backup codes |
| LastTwoFactorCodeUsed | string? | Anti-replay: hash of last OTP code used |
| LastTwoFactorCodeUsedAt | DateTime? | Anti-replay: timestamp of last OTP use |
| FailedLoginAttempts | int | Counter for lockout threshold |
| LockoutEnd | DateTime? | When lockout expires |
Anti-Replay Windowing & TOTP Security
To prevent token reuse attacks, the `Verify2FACommandHandler` implements a strict 60-second anti-replay window. It computes a SHA256 hash of the verification code and matches it against `LastTwoFactorCodeUsed` and checks `LastTwoFactorCodeUsedAt` timestamp. If the same code is used again within 60 seconds, it is rejected.
public bool CheckAndTrackTotpReplay(string code, Func<string, string> hashCode)
{
var hashedCode = hashCode(code);
if (LastTwoFactorCodeUsedAt.HasValue)
{
var timeSinceLastUse = DateTime.UtcNow - LastTwoFactorCodeUsedAt.Value;
if (timeSinceLastUse.TotalSeconds < 60 && LastTwoFactorCodeUsed == hashedCode)
{
return false; // Code replayed within the 60-second window
}
}
LastTwoFactorCodeUsed = hashedCode;
LastTwoFactorCodeUsedAt = DateTime.UtcNow;
return true;
}Tenant-Scoped Password Policy
Password requirements are configurable per-tenant via TenantSettings. Each tenant can set minimum length, uppercase requirements, numbers, special characters, and expiry duration. The PasswordLastChanged field on the Admin entity is checked against the tenant's PasswordExpiryDays to enforce password rotation.
| Setting | Default | Description |
|---|---|---|
| MinPasswordLength | 8 | Minimum characters |
| RequireUppercase | true | Must contain A-Z |
| RequireNumber | true | Must contain 0-9 |
| RequireSpecialCharacter | true | Must contain !@#$%... |
| PasswordExpiryDays | 90 | 0 = never expire |
| LockoutThreshold | 5 | Failed attempts before lockout |
| LockoutDurationMinutes | 30 | Lockout duration |
| Require2FA | false | Force 2FA for all admins in tenant |
Admin Auth Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/v1/auth/admin/login | Username + password login with discovery option | 🔒 Required |
| POST | /api/v1/auth/admin/refresh | Rotate refresh token and issue new JWT | 🔒 Required |
| POST | /api/v1/auth/admin/discover-workspaces | Discover all workspaces associated with email | 🔒 Required |
| POST | /api/v1/auth/admin/logout | Revoke current refresh token and clear session | 🔒 Required |
| GET | /api/v1/auth/admin/sessions | List all active sessions for current admin | 🔒 Required |
| DELETE | /api/v1/auth/admin/sessions/{tokenId} | Revoke specific active session by ID | 🔒 Required |
| POST | /api/v1/auth/admin/sessions/revoke-all | Revoke all sessions except the current one | 🔒 Required |
| POST | /api/v1/auth/admin/2fa/enable | Generate TOTP secret and QR code for activation | 🔒 Required |
| POST | /api/v1/auth/admin/2fa/confirm | Confirm 2FA setup by verifying code from authenticator | 🔒 Required |
| POST | /api/v1/auth/admin/2fa/verify | Verify 2FA code during login process | 🔒 Required |
| POST | /api/v1/auth/admin/2fa/disable | Disable 2FA for the account | 🔒 Required |
| POST | /api/v1/auth/admin/2fa/backup-codes/regenerate | Regenerate a new set of backup codes | 🔒 Required |
| GET | /api/v1/auth/admin/security-log | Get security activity log for the current admin | 🔒 Required |
User Auth Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/v1/auth/user/login | Email/phone + password login | 🔒 Required |
| POST | /api/v1/auth/user/login/{provider} | External provider login (Google, Facebook, Apple, Microsoft) | 🔒 Required |
| POST | /api/v1/auth/user/register | Self-registration using TenantCode | 🔒 Required |
| POST | /api/v1/auth/user/verify-email | Verify email address with OTP code | 🔒 Required |
| POST | /api/v1/auth/user/verify-phone | Verify phone number with OTP code | 🔒 Required |
| POST | /api/v1/auth/user/send-email-verification | Send or resend email verification OTP | 🔒 Required |
| POST | /api/v1/auth/user/send-phone-verification | Send or resend phone verification OTP | 🔒 Required |
| POST | /api/v1/auth/user/request-password-reset | Request password reset via email OTP | 🔒 Required |
| POST | /api/v1/auth/user/reset-password | Reset password using OTP code | 🔒 Required |
| POST | /api/v1/auth/user/refresh | Rotate refresh token and issue new JWT | 🔒 Required |
| POST | /api/v1/auth/user/logout | Revoke current refresh token and log out | 🔒 Required |
Rate Limiting
Authentication endpoints are protected by multiple rate limiting policies to prevent brute-force attacks and abuse.
| Policy Name | Window Type | Limit Threshold | Scope / Partition Key |
|---|---|---|---|
| Global | Fixed Window | 1000 requests per minute | Server-wide global DDoS ceiling |
| PerIp | Fixed Window | 200 requests per minute | Client IP Address |
| Login | Fixed Window | 10 requests per 5 minutes | Brute-force protection: login:{IP} |
| read-api | Sliding Window | 200 requests per minute | Infinite loop/scraping protection: read:{UserId/IP} |
| mutation-api | Sliding Window | 30 requests per minute | Automated modification abuse: mutation:{UserId/IP} |
| per-user | Sliding Window | 100 requests per minute | User-session activity ceiling: user:{UserId/IP} |
| export-heavy | Fixed Window | 5 requests per minute | High CPU reports/exports: export:{UserId/IP} |
| webhook | Sliding Window | 500 requests per minute | Webhook delivery burster: webhook:{IP} |
| signup | Fixed Window | 3 requests per hour | Registration/Tenant creation spam: signup:{IP} |
| phone-otp-send | Fixed Window | 3 requests per 15 minutes | SMS billing bombing prevention: phone-otp:{IP} |
| passkey-auth | Fixed Window | 5 requests per 15 minutes | WebAuthn brute-force protection: passkey:{IP} |
| qr-poll | Sliding Window | 60 requests per minute | Session polling limits: qr-poll:{IP} |
| password-reset | Fixed Window | 5 requests per 15 minutes | Recovery enumeration protection: password-reset:{IP} |
| token-refresh | Sliding Window | 20 requests per minute | Refresh farming prevention: token-refresh:{IP} |
Lockout Policies & brute-force limits
Brute-force protection sets `MaxFailedAttempts = 5` and a lockout duration of `LockoutMinutes = 15`. During a lockout, password validation is bypassed entirely to avoid timing attacks and CPU exhaustion, returning an immediate account locked error.