Auth Sessions & Token Management
Deep-dive into the six session and authentication entities: RefreshToken, OtpCode, QrLoginSession, WebAuthnChallenge, AdminPasskey, and ExternalLogin — including all fields, security semantics, and the QR login flow.
SCRIPE supports multiple concurrent authentication mechanisms. Each mechanism has a dedicated entity in the Identity module. RefreshToken manages sliding session windows. OtpCode handles time-limited one-time codes. QrLoginSession enables cross-device login via QR scanning. WebAuthnChallenge powers FIDO2 passkey ceremonies. AdminPasskey stores registered FIDO2 credentials. ExternalLogin links third-party identity providers to Admin and User accounts.
RefreshToken Entity
RefreshToken stores a long-lived opaque token issued alongside a JWT access token. Tokens are rotated on each use — the old token is revoked with a ReplacedByToken pointer, and a new token is issued. Impersonation sessions are tracked via ImpersonatorAdminId, enabling the StopImpersonation flow to restore the original admin's session.
| Field | Type | Notes |
|---|---|---|
| UserId | Guid? | FK → User. Mutually exclusive with AdminId |
| AdminId | Guid? | FK → Admin. Mutually exclusive with UserId |
| ImpersonatorAdminId | Guid? | Set when this token represents an impersonation session; points to the admin who initiated it |
| Token | string (max 500) | Opaque refresh token value — cryptographically random |
| ExpiresAt | DateTime | Hard expiry timestamp |
| RevokedAt | DateTime? | Set when the token is explicitly revoked |
| ReplacedByToken | string? (max 500) | New token value that replaced this one on rotation |
| RevokedReason | string? (max 200) | Human-readable reason for revocation |
| DeviceInfo | string? (max 500) | User-agent / device string for the session |
| IpAddress | string? (max 50) | IP address at token issuance |
| StaySignedIn | bool | Whether the user opted into extended session duration |
| IsActive | bool (computed) | true when RevokedAt == null && ExpiresAt > UtcNow |
public class RefreshToken : AuditableEntity<Guid>
{
public Guid? UserId { get; set; }
public Guid? AdminId { get; set; }
/// If set, this token represents an impersonation session.
/// Points to the admin who initiated the impersonation.
public Guid? ImpersonatorAdminId { get; set; }
[MaxLength(500)]
public string Token { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
public DateTime? RevokedAt { get; set; }
[MaxLength(500)]
public string? ReplacedByToken { get; set; }
[MaxLength(200)]
public string? RevokedReason { get; set; }
public bool StaySignedIn { get; set; }
public new bool IsActive => RevokedAt == null && ExpiresAt > DateTime.UtcNow;
public void Revoke(string reason, string? ipAddress = null, string? replacedByToken = null)
{
RevokedAt = DateTime.UtcNow;
RevokedReason = reason;
IpAddress = ipAddress;
ReplacedByToken = replacedByToken;
}
}OtpCode Entity
OtpCode is a polymorphic one-time code that serves both users and admins. The Purpose integer maps to an enum (email verification, password reset, 2FA, etc.). Codes are invalidated via Invalidate() after successful use. The Attempts / MaxAttempts pair implements brute-force protection.
| Field | Type | Notes |
|---|---|---|
| UserId | Guid? | FK → User. Set for user-facing OTP flows |
| AdminId | Guid? | FK → Admin. Set for admin-facing OTP flows |
| Code | string (max 10) | The numeric OTP value (e.g., '483921') |
| Purpose | int | Enum integer: identifies the OTP purpose (email verify, password reset, etc.) |
| ExpiresAt | DateTime | OTP validity window end |
| Attempts | int | Number of failed validation attempts so far |
| MaxAttempts | int (default 5) | Maximum allowed failed attempts before lock-out |
| IsUsed | bool | Set to true by Invalidate() after successful use |
| string (max 200) | Email address the OTP was sent to |
QrLoginSession Entity
QrLoginSession orchestrates cross-device login: a desktop browser creates a session (status = Pending) and displays a QR code containing the SessionToken. An authenticated mobile device scans the QR (Scanned), the user approves (Approved), the backend generates tokens, and the polling desktop browser consumes them (Consumed). Sessions are cleaned up by QrSessionCleanupJob after the 5-minute TTL.
| Field | Type | Notes |
|---|---|---|
| SessionToken | string (max 200) | 64-byte cryptographically random token embedded in the QR code (base64url) |
| Status | QrSessionStatus | Pending → Scanned → Approved → Consumed (or Rejected / Expired) |
| InitiatorIp | string? (max 50) | IP address of the desktop browser that generated the QR |
| InitiatorUserAgent | string? (max 500) | User-Agent of the initiating browser (shown to approver for verification) |
| ApprovedByAdminId | Guid? | Admin who approved the session from their authenticated mobile device |
| ApprovedAt | DateTime? | Timestamp of mobile approval |
| AccessToken | string? (max 2000) | JWT access token generated for the desktop after approval; consumed once |
| RefreshTokenValue | string? (max 500) | Refresh token value issued for the new desktop session |
| TokenExpiresAt | DateTime? | Expiry of the issued JWT access token |
| ExpiresAt | DateTime | Session TTL — 5 minutes from creation |
| TenantId | Guid? | Optional tenant scope for the login session |
| CreatedAt | DateTime | Session creation timestamp (UTC) |
WebAuthnChallenge Entity
WebAuthnChallenge is a short-lived (5-minute) server-side nonce generated at the start of each WebAuthn ceremony (registration or authentication). The challenge is sent to the browser, signed by the authenticator, and verified on return. IsUsed = true prevents replay attacks. Origin binding prevents cross-origin ceremony hijacking.
| Field | Type | Notes |
|---|---|---|
| Challenge | string (max 200) | Cryptographically random bytes (base64url) sent to the authenticator |
| AdminId | Guid? | Admin performing the ceremony. Null during discoverable (usernameless) authentication |
| CeremonyType | string (max 20) | "registration" or "authentication" — used to match the challenge at verification |
| ExpiresAt | DateTime | 5-minute TTL from creation |
| IsUsed | bool | Marked true after ceremony completion to prevent replay |
| Origin | string? (max 200) | Origin (scheme + host) that initiated the ceremony; verified to prevent cross-origin attacks |
AdminPasskey Entity
AdminPasskey stores a registered FIDO2/WebAuthn credential for an admin. Each admin can have multiple passkeys (Touch ID, YubiKey, Windows Hello, etc.). The SignatureCounter is incremented by the authenticator on each use — a counter that goes backwards indicates a cloned credential. IsDiscoverable = true enables true passwordless login (no username entry required).
| Field | Type | Notes |
|---|---|---|
| AdminId | Guid | FK → Admin who owns this passkey |
| CredentialId | string (max 1024) | Unique credential ID from the authenticator (base64url). Used to look up the credential during authentication |
| PublicKey | string (max 2048) | COSE public key (base64url). Used to verify assertion signatures |
| SignatureCounter | uint | Incremented by the authenticator on each use. Backwards counter = cloned credential |
| Aaguid | string? (max 36) | Authenticator model identifier (e.g., YubiKey 5, iCloud Keychain). All-zeros for privacy-preserving devices |
| DeviceName | string (max 100) | User-assigned friendly name (e.g., 'MacBook Touch ID', 'YubiKey 5C NFC') |
| AttestationType | string (max 20) | Attestation type used at registration: none / indirect / direct / enterprise |
| Transports | string? (max 100) | Comma-separated transport hints: usb, nfc, ble, internal |
| LastUsedAt | DateTime? | When this passkey was last successfully used |
| IsDiscoverable | bool | true = resident credential enabling passwordless login without a username |
ExternalLogin Entity
ExternalLogin creates a polymorphic link between an external identity (any OAuth / OIDC / SAML provider) and an Admin or User. AdminId and UserId are mutually exclusive — an external login linked to an Admin cannot authenticate a User. IdentityProviderId is null for built-in social providers (Google, Facebook, Apple, Microsoft) and set for custom OIDC/SAML providers configured per-tenant.
| Field | Type | Notes |
|---|---|---|
| AdminId | Guid? | FK → Admin. Mutually exclusive with UserId |
| UserId | Guid? | FK → User. Mutually exclusive with AdminId |
| ProviderName | string (max 50) | Provider identifier: 'google', 'facebook', 'apple', 'microsoft', 'azure-ad', or an IdentityProvider.Slug for custom OIDC |
| ProviderKey | string (max 500) | The user's unique ID in the external system (the 'sub' claim in OIDC) |
| string? (max 200) | Email from the external provider — used for initial account matching | |
| DisplayName | string? (max 200) | Display name from the provider (for UI purposes) |
| IdentityProviderId | Guid? | FK → IdentityProvider config. Null for built-in social providers (Google, Facebook, Apple, Microsoft) |
| LinkedAt | DateTime | When this external identity was linked to the Admin/User |
| LastUsedAt | DateTime? | When this external identity was last used to authenticate |