Login flows
Every login path in detail. Endpoints are mounted under /api/account/... (see MapAccountEndpoints in Modgud.Api/Program.cs).
Login flow overview
After RequiresMfa the client must send a second request:
- TOTP:
POST /api/account/mfa/login - Email OTP:
POST /api/account/email-otp/login - Passkey:
POST /api/account/passkey/login/complete
After a successful second step the session is fully established — the Modgud.Auth cookie is set, and all following requests run authenticated.
Password login
POST /api/account/login
Content-Type: application/json
{
"username": "admin",
"password": "ABC12abc!",
"rememberMe": true
}Possible responses:
| Response | Meaning |
|---|---|
200 { authenticated: true } | Login complete — cookie set |
200 { requiresTwoFactor: true, mfaMethods: [...] } | Level ≥ 1, user has 2FA — second step needed |
200 { requiresSecureSetup: true, gracePeriod: true, secureSetupDueAt } | User still has to set up 2FA, time runs until DueAt |
200 { requiresSecureSetup: true, gracePeriod: false } | Grace period over, blocking |
401 Invalid credentials | Username/password wrong or user locked |
403 Passwordless required | Level = 2, password login disabled |
TOTP login
POST /api/account/mfa/login
Content-Type: application/json
{
"code": "123456",
"rememberMe": true
}Reads the Modgud.2FA cookie set by /login, which holds the UserId for 5 minutes. Verifies the code via UserManager.VerifyTwoFactorTokenAsync. On success the session is fully established.
Email OTP login
POST /api/account/email-otp/login/request
Content-Type: application/json
{ "userName": "alice" }Sends a 6-digit code by email. Rate-limited via EmailOtpConfiguration.RateLimitMinutes. Verify:
POST /api/account/email-otp/login
Content-Type: application/json
{ "userName": "alice", "code": "123456", "rememberMe": true }A maximum of 3 verify attempts per challenge; otherwise a new code must be requested.
Passkey login (FIDO2 / WebAuthn)
Two-step ceremony. First fetch options:
POST /api/account/passkey/login/options
Content-Type: application/json
{ "userName": "alice" }The response contains the AssertionOptions (challenge, RpId, allowCredentials). The browser calls navigator.credentials.get(...), the user touches their passkey. The response is sent to:
POST /api/account/passkey/login/complete
Content-Type: application/json
{ "assertion": { ... } }The server verifies the assertion, checks the sign count against StoredPasskeyCredential.SignCount (replay protection) and sets a persistent cookie (30 days).
Passwordless via Passkey (without userName)
When POST /api/account/passkey/login/options is called without userName, the server generates AssertionOptions with an empty AllowedCredentials list. The browser uses discoverable credentials (resident keys) — the user picks a stored identity from the authenticator. The UserId is read from the UserHandle of the assertion.
Magic Link login
Self-service request:
POST /api/account/magic-link/request
Content-Type: application/json
{ "email": "alice@example.com" }Sends an email with a ?token=...&user=... link. The click opens:
GET /api/account/magic-link/login?token=...&user=...The backend hashes the token, compares it with MagicLinkChallenge.TokenHash, checks expiry and sets a persistent cookie. Redirect to the frontend.
Admin-send instead of self-service
Admins can send a link without any feature toggle via POST /api/admin/users/{id}/magic-link. Used for emergency access and onboarding.
OIDC external login
Three endpoints:
GET /api/account/external-login/{idpConfigId}/start?returnUrl=/→ ASP.NET Core Challenge with the dynamically registered OIDC scheme (DynamicOidcSchemeManager). Browser lands at the external IdP.
GET /api/account/external-login/callback→ ExternalLoginProcessor runs:
- Looks up
ExternalIdentityLink(Issuer + Subject) → existing user or JIT create UserUpdateScriptRunnerrunsIdpConfig.UserUpdateScript(Jint) → maps claims to a{ firstname, lastname, email, acronym }patch- Email conflict (email belongs to a different user) → hard reject (
Idp.EmailConflict) - Login cookie set (persistent, 30 days)
Details on IdP setup and scripting: see Login Providers (OIDC).
OAuth authorize flow (external apps)
Different topic — an external app starts an OAuth flow against Modgud via /connect/authorize. If the user is not logged in, they are redirected to the login UI, run through the regular login flow above, come back to /connect/authorize and receive an authorization code. See OAuth & OIDC and OpenIddict wiring.
Logout
POST /api/account/logoutDeletes the auth cookie + invalidates the UserSession in Marten. In the frontend the logout composable performs a window.location reload (not just a Vue Router navigation) so that the SignalR connection tears down cleanly. Otherwise an old subscription would hang on the previous user.