Edge Cases & Troubleshooting
This guide covers every non-happy-path scenario in the Identity Broker, organised by feature area. For each scenario you will find: what happens, why it happens, how to handle it in your code, and what to look for in the audit log.
Find the scenario that matches your situation, read the "What happens" note to confirm the behaviour, then follow the "How to handle it" advice to resolve it in your application.
1. Local Login
1.1 User does not exist
What happens: 401 Unauthorized. The response body is identical to the "wrong password" response.
Why: Returning different messages for "email not found" vs. "wrong password" allows attackers to enumerate valid email addresses.
How to handle it: Display a generic message such as "Incorrect email or password" — never tell the user which part was wrong.
Audit event: LoginFailed with details "email_not_found".
1.2 Wrong password
What happens: 401 Unauthorized — same response as 1.1.
Audit event: LoginFailed with details "invalid_credentials".
1.3 Valid credentials but MFA not enrolled (Mfa.Enabled = true, RequireAllUsers = false)
What happens: Login succeeds normally and a session cookie is set. MFA is opt-in by default.
1.4 Valid credentials, MFA enrolled
What happens: 202 Accepted — no session cookie yet. The response body contains a mfaPendingToken. Send POST /api/auth/mfa/verify next.
1.5 Login blocked by rate limit
What happens: 429 Too Many Requests. The Retry-After header tells the frontend how many seconds to wait.
How to handle it: Disable the submit button and show a countdown timer. Do not retry silently.
Audit event: RateLimitExceeded.
1.6 Missing CSRF token on login request
What happens: 400 Bad Request. No credential check is performed.
How to handle it: Ensure GET /api/auth/providers was called before the login form was shown. Read the XSRF-TOKEN cookie and include its value in the X-Primus-CSRF request header on every POST.
1.7 Case sensitivity in email
What happens: Credential validation is delegated to your IPrimusAuthCredentialValidator. Whether Alice@EXAMPLE.COM and alice@example.com are treated as the same account depends on your implementation.
Recommendation: Normalise email addresses to lowercase before storing and before looking them up.
2. MFA: TOTP
2.1 Wrong TOTP code
What happens: POST /mfa/verify returns 401 Unauthorized. The pendingToken remains valid until it expires — the user can try again.
Audit event: MfaFailed.
2.2 Expired pending token
What happens: POST /mfa/verify returns 422 Unprocessable Entity. The pending token TTL (default: 5 minutes) has elapsed.
How to handle it: The user must return to the login form and submit their credentials again.
2.3 Pending token reuse within expiry window
What happens: POST /mfa/verify returns 422 Unprocessable Entity on the second or any subsequent call with the same mfaPendingToken. Pending tokens are single-use — the SDK marks each token as consumed via IPrimusConsumedPendingTokenStore on the first successful call. Replaying the same token is rejected immediately, even if the token has not yet expired.
Implication for your app: No action required — single-use enforcement is built-in. If you supply a custom IPrimusConsumedPendingTokenStore backed by a distributed cache, the protection extends across multiple application instances.
2.4 TOTP clock drift
What happens: A valid TOTP code is rejected with 401. The Broker accepts codes within a ±30 s tolerance. Beyond ±30 s, codes fail.
How to fix: Ensure NTP synchronisation on the user's device. Enable "automatic time" on mobile devices.
2.5 User lost their authenticator app
Recommended recovery flow:
- On the MFA step, offer a "Use a backup code instead" option.
- User enters a backup code in the
codefield ofPOST /mfa/verify. - If all backup codes are exhausted, an admin must call
IPrimusMfaStore.DisableMfaAsync()for the user account.
2.6 Enrolment token expired
What happens: POST /mfa/enroll/confirm returns 422. The user must restart with a fresh POST /mfa/enroll.
2.7 Wrong code during enrolment confirmation
What happens: POST /mfa/enroll/confirm returns 422. The user must restart with POST /mfa/enroll to get a fresh secret and QR code.
3. MFA: Global Enforcement (RequireAllUsers)
3.1 Unenrolled user logs in when RequireAllUsers = true
What happens: 403 Forbidden.
{
"mfaSetupRequired": true,
"message": "MFA enrollment is mandatory for this application. Please enroll before continuing."
}
Audit event: MfaEnforcementBlocked.
How to handle it: Detect 403 + mfaSetupRequired: true. Redirect to your MFA enrolment page. The user must complete /mfa/enroll + /mfa/enroll/confirm before they can log in.
3.2 RequireAllUsers = true but Mfa.Enabled = false
What happens: RequireAllUsers is silently ignored. Both must be true for enforcement to apply.
How to check: Verify "Mfa": { "Enabled": true, "RequireAllUsers": true } in your configuration.
3.3 Trying to disable MFA when RequireAllUsers = true
What happens: POST /mfa/disable returns 403 Forbidden. A user cannot disable MFA in an app that mandates it.
How to handle it: Do not show the "Disable MFA" UI option when RequireAllUsers = true. If a user must reset MFA (e.g., lost authenticator), an admin must call IPrimusMfaStore.DisableMfaAsync() directly.
3.4 OIDC SSO + RequireAllUsers = true
What happens: After a successful OIDC callback, if the mapped user is not enrolled in MFA, context.Fail("mfa_setup_required") is called and no session is issued.
How to handle it: Configure a dedicated OnRemoteFailure handler to detect mfa_setup_required and redirect to your MFA enrolment page.
4. MFA: Backup Codes
4.1 All backup codes exhausted
What happens: POST /mfa/verify with a backup code returns 401. An admin must call DisableMfaAsync(userId) to reset the user's MFA state.
4.2 Backup code replay attack
What happens: A used backup code returns 401. IPrimusMfaStore.ConsumeBackupCodeAsync() marks codes as consumed — they cannot be reused.
4.3 Backup codes not stored by the user
Recommendation: On the enrolment confirmation screen, force the user to acknowledge that they have saved their backup codes. Consider offering a "download as text file" option.
5. OIDC SSO
5.1 OIDC configuration missing or wrong
| Symptom | Likely Cause | Fix |
|---|---|---|
"Invalid client_id" from provider | Wrong ClientId in config | Re-copy from your app registration |
"Redirect URI mismatch" from provider | Callback URL not registered in IdP | Register the Broker's callback path in your IdP (e.g. https://yourapp.com/api/auth/azure/callback for Azure AD, https://yourapp.com/api/auth/auth0/callback for Auth0) |
"Tenant not found" (Azure) | Wrong TenantId | Verify the tenant GUID in Azure Portal |
| Redirect loop after callback | Callback path duplicated | Ensure CallbackPath matches the ASP.NET OIDC middleware default |
5.2 OIDC user not mapped to a local account
What happens: If FindByEmailAsync() or AutoProvisionUserAsync() throws, the callback fails and the user is not logged in.
How to handle it: Ensure your implementation handles both returning an existing user and creating a new user on first login.
5.3 Open redirect via returnUrl
What happens: An absolute URL pointing to an external domain is rejected — the user is redirected to the default post-login URL. GET /api/auth/azure?returnUrl=https://evil.com cannot succeed.
5.4 SAML IdP not configured
What happens: Both GET /api/auth/saml/{scheme} (SP metadata) and GET /api/auth/saml/{scheme}/SignIn (SSO initiation) return 404 if no SAML IdP with that scheme name has been configured. The GET /api/auth/providers response will not include the scheme.
6. Session Management
6.1 Session cookie missing on request
What happens: 401 Unauthorized on authenticated endpoints.
Common causes for cross-origin SPAs: Cookie is SameSite=Strict so the browser did not include it.
Fix: Set CookieSameSite = "None" and CookieSecure = true. Ensure CORS allows credentials from the SPA origin. Send requests with credentials: "include" (fetch) or withCredentials: true (Axios).
6.2 Session revoked (JTI invalidated)
What happens: DELETE /sessions/{jti} marks the specified JTI as revoked in the IPrimusSessionRevocationStore. The cookie authentication middleware consults the revocation store on every authenticated request via an OnValidatePrincipal hook wired by AddPrimusAuthBroker. A revoked cookie is immediately rejected with 401 Unauthorized on the next request — there is no grace window.
Implication for your app: No additional configuration is required. Out of the box, InMemorySessionRevocationStore handles single-process deployments. For multi-node or distributed deployments, supply a custom IPrimusSessionRevocationStore backed by Redis or a shared database so revocations propagate across all instances.
When DELETE /sessions is used: The logout endpoint signs the cookie out of the current request and marks the JTI as revoked, providing immediate cross-request protection on the same instance.
6.3 Concurrent sessions
What happens: If you implement IPrimusSessionRevocationStore with a per-user session cap, you can track active JTIs and evict the oldest one when a new login would exceed your configured limit.
Audit event: SessionRevoked (actor = "System") — fired by your store implementation when it revokes an old JTI.
6.4 Cookie not sent over HTTP in development
What happens: The browser refuses to send the cookie because CookieSecure = true but the request is over HTTP.
Fix: Set CookieSecure = false in appsettings.Development.json only.
7. CSRF Protection
7.1 CSRF cookie not set before login
What happens: 400 Bad Request.
Fix: Call GET /api/auth/providers during app initialisation (e.g., in ngOnInit, useEffect, or main.ts), not just when the login page loads.
7.2 CSRF token in header does not match cookie
What happens: 400 Bad Request. The X-Primus-CSRF header value does not match the XSRF-TOKEN cookie.
Common cause: The XSRF-TOKEN cookie was not set before the request was sent — GET /api/auth/providers (or any other broker endpoint) had not been called yet, so no cookie existed.
Fix: Read the XSRF-TOKEN cookie value immediately before each mutating request and pass it in the X-Primus-CSRF header. The cookie is stable once set — it is not rotated between requests.
7.3 SameSite cookie policy conflicts with cross-origin setup
What happens: The CSRF cookie is not sent by the browser on cross-origin requests, so the double-submit check always fails.
Fix: For cross-origin SPAs, set CookieSameSite = "None" and CookieSecure = true.
8. Rate Limiting
8.1 Legitimate user locked out after 5 fast attempts
How to handle it: Show a clear error message with the remaining wait time (from Retry-After). Do not retry silently.
8.2 Shared IP (NAT / corporate proxy)
What happens: All users behind a corporate NAT share one IP. A single user triggering the rate limit locks out all colleagues temporarily.
How to handle it: Increase MaxAttempts and WindowSeconds, or implement a custom IPrimusAuthRateLimiter keyed by userId.
8.3 Rate limiter not distributed (multi-replica deployments)
What happens: Each instance has its own counter. An attacker with 2 replicas gets 5 + 5 = 10 attempts.
Fix: Implement a distributed rate limiter (e.g., Redis-backed) and register it as IPrimusAuthRateLimiter.