Modgud has two orthogonal authentication axes:
- First-party login — the user signs in to modgud itself (admin UI, profile, setup). Cookie-based, no token in the browser.
- OAuth/OIDC server — external apps let users sign in via modgud. Authorization Code + PKCE, classic.
Both share the same login methods under the hood.
Implemented in the Authentication slice
(Modgud.Authentication). Endpoints mounted under /api/account/....
| Method | When | Cookie lifetime |
|---|---|---|
| Password | Default, allowed at AuthLevel 0/1 | Session or 30 days (RememberMe) |
| TOTP | Second factor after password | Inherits from the password step |
| Email OTP | Second factor — or as an alternative login | Inherits from the password step |
| Passkey (FIDO2) | Second factor — or as a sole login (passwordless) | Always 30 days (persistent) |
| Magic Link | Email with single-use token; can also be sent by an admin | Always 30 days |
| OIDC External | Federated login via Entra ID, Google, ... | 30 days |
See Login flows for details.
Configured globally via IAuthSettings.AuthenticationMinimumLevel:
| Level | Effect |
|---|---|
| 0 = None | Password-only allowed — no enforcement |
| 1 = SecureLogin (default) | User must have 2FA or a passwordless method |
| 2 = Passwordless | Password login disabled — only Magic Link + Passkey |
At level >= 1 the TwoFactorEnforcementMiddleware runs and blocks
authenticated requests from users without 2FA (with a grace period).
| Cookie | Purpose | SameSite | Lifetime |
|---|---|---|---|
Modgud.Auth |
Main session (HttpOnly) | Lax | Session or 30 days |
Modgud.2FA |
UserId between password step and 2FA step | Strict | 5 min |
Modgud.External |
OIDC callback holder | Lax | 10 min |
Modgud.Session |
Only for passkey attestation options | Strict | 5 min idle |
SameSite=Lax on the main session cookie is required so that OIDC
redirect-back navigations carry the cookie (top-level GET → cookie sent).
Cross-site POSTs are still blocked by SameSite=Lax, plus the
CsrfDefenseMiddleware rejects state-changing requests whose
Sec-Fetch-Site indicates cross-origin.
In production all cookies are Secure. In dev Secure=None so the
Vite dev server (http://localhost:4300) can write them.
Modgud is at the same time a full-fledged OpenID Connect provider for external apps. Implemented via OpenIddict 7 with its own Marten-based stores (no Entity Framework).
sequenceDiagram
participant App as External App
participant Auth as modgud
participant User
App->>Auth: GET /connect/authorize?...&code_challenge=...
Auth->>User: Login page (if needed)
User->>Auth: User signs in (password + 2FA)
Auth->>Auth: Consent (implicit or explicit)
Auth->>App: Redirect with ?code=...
App->>Auth: POST /connect/token (code + verifier)
Auth->>App: access_token + id_token + refresh_token
Supported: Authorization Code + PKCE, Client Credentials, Refresh Token.
Not supported: Implicit Flow, ROPC.
See OAuth & OIDC and OAuth implementation for details.
Each realm is its own OIDC provider with its own discovery document at
https://<realm-domain>/.well-known/openid-configuration. Tokens from
realm A do not work in realm B — the issuer check blocks them.
This is implemented by the RealmIssuerHandler (an OpenIddict pipeline
hook): at boot there is a static issuer; the handler overrides it per
request with BaseUri (the current realm domain).
Three independent 2FA methods, freely combinable:
| Method | How it works |
|---|---|
| TOTP | Authenticator app (Google Authenticator, Authy) — RFC 6238 |
| Email OTP | One-time code by email to the verified address |
| WebAuthn/Passkey | Hardware keys (YubiKey) or platform authenticators (TouchID, Windows Hello) |
Plus recovery codes as a last-resort backup.
::: warning Passkeys are bound to the realm's primary domain
A passkey is registered against a WebAuthn relying-party ID, and Modgud uses the realm's PrimaryDomain as that ID. A passkey therefore only works when the user reaches the realm on its primary domain — not via a secondary domain in the realm's Domains list — and changing the realm's PrimaryDomain invalidates every existing passkey (affected users must re-register). See Realms — primary domain.
:::
Users can sign in via external OIDC providers (Entra ID, Google, Auth0, ...). Configurable per realm.
- Admin creates a
LoginProviderofType = Oidc: authority, client ID, client secret,UserUpdateScript - Login page automatically shows buttons for enabled OIDC providers
- Click → OIDC Authorization Code + PKCE → IdP login
- On callback:
ExternalLoginProcessorruns- Looks up
ExternalIdentityLink(issuer + subject) → existing user or JIT-create UserUpdateScript(Jint) maps claims to user fields
- Looks up
- If the user has 2FA enabled, the normal 2FA flow runs afterwards
- Login cookie is set (always 30 days)
See Login providers (OIDC) for details.
| How does a user enter the system? | Mechanism |
|---|---|
| Self-registration | Registration form (when enabled for the realm) |
| External login | OIDC IdP → JIT-create on first login |
| Admin-created | Admin creates the user via the UI |
| Setup | First-time setup — the first user becomes system admin |
Lifecycle states:
- Active — normal state
- Locked — by account lockout (5 failed logins → 1 min)
- Soft-deleted —
IsDeleted = true, all data preserved, reactivatable - GDPR-erased — stream archived, PII masked, irreversible (Article 17)
Detailed slice-internal walkthrough lives in the repo-only Authentication slice blueprint notes.